diff --git a/src/app.tsx b/src/app.tsx index f853ace..0cf6663 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -3,9 +3,14 @@ import queryString from 'query-string'; import {useEffect, useState} from 'react'; import {FormattedMessage, useIntl} from 'react-intl'; import {Navigate, Route, Routes, useLocation, useNavigate} from 'react-router'; -import {Loader, Message, Portal, Tab} from 'semantic-ui-react'; +import { + Loader, + Message, + Portal, + SidebarPushable, + SidebarPusher, +} from 'semantic-ui-react'; import {IndiInfo} from 'topola'; -import {Changelog} from './changelog'; import { Chart, ChartType, @@ -14,15 +19,6 @@ import { downloadSvg, printChart, } from './chart'; -import { - argsToConfig, - Config, - ConfigPanel, - configToArgs, - DEFALUT_CONFIG, - Ids, - Sex, -} from './config'; import {DataSourceEnum, SourceSelection} from './datasource/data_source'; import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded'; import { @@ -38,14 +34,21 @@ import { WikiTreeDataSource, WikiTreeSourceSpec, } from './datasource/wikitree'; -import {Details} from './details/details'; import {DonatsoChart} from './donatso-chart'; import {Intro} from './intro'; import {TopBar} from './menu/top_bar'; +import { + argsToConfig, + Config, + configToArgs, + DEFALUT_CONFIG, + Ids, + Sex, +} from './sidepanel/config/config'; +import {SidePanel} from './sidepanel/side-panel'; import {analyticsEvent} from './util/analytics'; import {getI18nMessage} from './util/error_i18n'; import {idToIndiMap, TopolaData} from './util/gedcom_util'; -import {Media} from './util/media'; /** * Load GEDCOM URL from VITE_STATIC_URL environment variable. @@ -241,7 +244,7 @@ export function App() { } } - function toggleDetails(config: Config, data: TopolaData | undefined) { + function updateChartWithConfig(config: Config, data: TopolaData | undefined) { if (data === undefined) { return; } @@ -254,6 +257,14 @@ export function App() { }); } + function onToggleSidePanel() { + const newShowSidePanel = !showSidePanel; + setShowSidePanel(newShowSidePanel); + updateUrl({ + sidePanel: newShowSidePanel ? 'true' : 'false', + }); + } + /** Sets error message after data load failure. */ function setErrorMessage(message: string) { setError(message); @@ -362,7 +373,7 @@ export function App() { const data = await loadData(args.sourceSpec, args.selection); // Set state with data. setData(data); - toggleDetails(args.config, data); + updateChartWithConfig(args.config, data); setShowSidePanel(args.showSidePanel); setState(AppState.SHOWING_CHART); } catch (error: any) { @@ -510,33 +521,6 @@ export function App() { case AppState.SHOWING_CHART: case AppState.LOADING_MORE: const updatedSelection = getSelection(data!.chartData, selection); - const sidePanelTabs = [ - { - menuItem: intl.formatMessage({ - id: 'tab.info', - defaultMessage: 'Info', - }), - render: () => ( -
- ), - }, - { - menuItem: intl.formatMessage({ - id: 'tab.settings', - defaultMessage: 'Settings', - }), - render: () => ( - { - setConfig(config); - toggleDetails(config, data); - updateUrl(configToArgs(config)); - }} - /> - ), - }, - ]; return (
) : null} - {renderChart(updatedSelection)} - {showSidePanel ? ( - - - - ) : null} - + + { + setConfig(config); + updateChartWithConfig(config, data); + updateUrl(configToArgs(config)); + }} + /> + {renderChart(updatedSelection)} +
); diff --git a/src/chart.tsx b/src/chart.tsx index f1515cf..5765211 100644 --- a/src/chart.tsx +++ b/src/chart.tsx @@ -14,6 +14,7 @@ import {useEffect, useRef} from 'react'; import {IntlShape, useIntl} from 'react-intl'; import { ChartHandle, + ChartInfo, CircleRenderer, createChart, DetailedRenderer, @@ -24,7 +25,7 @@ import { RelativesChart, ChartColors as TopolaChartColors, } from 'topola'; -import {ChartColors, Ids, Sex} from './config'; +import {ChartColors, Ids, Sex} from './sidepanel/config/config'; import {Media} from './util/media'; import {usePrevious} from './util/previous-hook'; @@ -252,6 +253,40 @@ function getRendererType(chartType: ChartType) { } } +/** Returns the element’s usable width and height by subtracting the assumed scrollbar size. */ +function getScrollbarAwareSize( + element: Element, + scrollbarSize = 20, +): [number, number] { + const htmlElement = element as HTMLElement; + return [ + htmlElement.clientWidth - scrollbarSize, + htmlElement.clientHeight - scrollbarSize, + ]; +} + +/** + * Calculates the allowed zoom scale range. + * Sets the minimum scale so the chart cannot zoom out beyond full visibility, + * and fixes the maximum scale at 2. + */ +function calculateScaleExtent( + parent: Element, + scale: number, + chartInfo: ChartInfo, +): [number, number] { + const [availWidth, availHeight] = getScrollbarAwareSize(parent); + + const zoomOutFactor = min([ + 1, + scale, + availWidth / chartInfo.size[0], + availHeight / chartInfo.size[1], + ])!; + + return [max([0.1, zoomOutFactor])!, 2]; +} + export interface ChartProps { data: JsonGedcomData; selection: IndiInfo; @@ -328,20 +363,18 @@ class ChartWrapper { }); const svg = select('#chartSvg'); const parent = select('#svgContainer').node() as Element; - const scale = zoomTransform(parent).k; - const zoomOutFactor = min([ - 1, + const extent: [number, number] = calculateScaleExtent( + parent, scale, - parent.clientWidth / chartInfo.size[0], - parent.clientHeight / chartInfo.size[1], - ])!; - const extent: [number, number] = [max([0.1, zoomOutFactor])!, 2]; + chartInfo, + ); this.zoomBehavior = zoom() .scaleExtent(extent) .translateExtent([[0, 0], chartInfo.size]) .on('zoom', (event) => zoomed(chartInfo.size, event)); + select(parent).on('scroll', scrolled).call(this.zoomBehavior); const scrollTopTween = (scrollTop: number) => { diff --git a/src/index.css b/src/index.css index 410ba08..ce21126 100644 --- a/src/index.css +++ b/src/index.css @@ -14,9 +14,15 @@ body, html { #content { flex: 1 1 auto; - display: flex; - overflow: hidden; height: 100%; + overflow: hidden; +} + +#introContent { + flex: 1 1 auto; + height: 100%; + overflow: hidden; + display: flex; } #svgContainer { @@ -24,13 +30,6 @@ body, html { overflow: auto; } -.sidePanel { - flex: 0 0 320px; - overflow: auto; - border-left: solid #ccc 1px; - overflow-x: hidden; -} - .hidden { display: none; } @@ -147,10 +146,12 @@ div.zoom { .details { padding: 15px 0px; border-bottom: 1px solid rgba(34,36,38,.15); + overflow-y: auto; + height: calc(100% - 43px); } .details .ui.items .item .content { - padding: 0 15px; + padding: 0 15px 0 25px; } .details .item-header { @@ -166,8 +167,6 @@ div.zoom { } .details .person-image { - max-width: 289px; - width: 289px; padding: 0 10px; } @@ -278,3 +277,85 @@ div.zoom { #dotatsoSvgContainer .link { stroke: black; } + +#content .pusher { + height: 100%; + display: flex; + overflow: hidden; +} + +#content #sidebar.wide ~ .pusher { + width: calc(100% - 350px); + transform: none; +} + +#content #sidebar.very.thin ~ .pusher { + width: calc(100% - 60px); +} + +#sidebar { + overflow: hidden; + height: 100%; +} + +#sideTabs { + height: 100%; +} + +#sideTabs > .ui.tabular.menu{ + padding-left: 60px; +} + +#sideToggle { + position: absolute; + background: inherit; + top: 0; + height: 42px; + border-radius: 0; + width: 60px; +} + +#sideToggle:hover { + background-color: #ddd; +} + +.collapsed-details { + width: 100%; + height: 100%; + display: grid; + place-items: center; +} + +.collapsed-details .vertical-name { + writing-mode: vertical-rl; + transform: rotate(180deg); + transform-origin: center; + font-size: 1.2rem; + font-weight: bold; + white-space: pre; +} + +@media (max-width: 767px) { + #sidebar.wide { + width: 100%; + } + + #sidebar.very.thin { + width: 35px; + } + + #content #sidebar.wide ~ .pusher { + width: 0; + } + + #content #sidebar.very.thin ~ .pusher { + width: calc(100% - 35px); + } + + #sidebar.very.thin #sideToggle { + width: 35px; + } +} + + + diff --git a/src/intro.tsx b/src/intro.tsx index a4512ea..b4e97d8 100644 --- a/src/intro.tsx +++ b/src/intro.tsx @@ -141,7 +141,7 @@ function Contents() { /** The intro page. */ export function Intro() { return ( -
+
diff --git a/src/config.tsx b/src/sidepanel/config/config.tsx similarity index 100% rename from src/config.tsx rename to src/sidepanel/config/config.tsx diff --git a/src/details/additional-files.tsx b/src/sidepanel/details/additional-files.tsx similarity index 100% rename from src/details/additional-files.tsx rename to src/sidepanel/details/additional-files.tsx diff --git a/src/sidepanel/details/collapsed-details.tsx b/src/sidepanel/details/collapsed-details.tsx new file mode 100644 index 0000000..9a972f9 --- /dev/null +++ b/src/sidepanel/details/collapsed-details.tsx @@ -0,0 +1,26 @@ +import {FormattedMessage} from 'react-intl'; +import {GedcomData} from '../../util/gedcom_util'; + +interface Props { + gedcom: GedcomData; + indi: string; +} + +export function CollapsedDetails(props: Props) { + const entries = props.gedcom.indis[props.indi].tree; + const nameEntry = entries.find((entry) => entry.tag === 'NAME'); + + const fullName = nameEntry?.data.replaceAll('/', '') ?? ''; + + return ( +
+ {fullName ? ( + {fullName} + ) : ( + + + + )} +
+ ); +} diff --git a/src/details/details.tsx b/src/sidepanel/details/details.tsx similarity index 99% rename from src/details/details.tsx rename to src/sidepanel/details/details.tsx index 19c818d..7eeab17 100644 --- a/src/details/details.tsx +++ b/src/sidepanel/details/details.tsx @@ -10,7 +10,7 @@ import { getImageFileEntry, getNonImageFileEntry, mapToSource, -} from '../util/gedcom_util'; +} from '../../util/gedcom_util'; import {AdditionalFiles} from './additional-files'; import {ALL_SUPPORTED_EVENT_TYPES, Events} from './events'; import {MultilineText} from './multiline-text'; diff --git a/src/details/event-extras.tsx b/src/sidepanel/details/event-extras.tsx similarity index 98% rename from src/details/event-extras.tsx rename to src/sidepanel/details/event-extras.tsx index 3929ea5..b756832 100644 --- a/src/details/event-extras.tsx +++ b/src/sidepanel/details/event-extras.tsx @@ -9,7 +9,7 @@ import { Popup, Tab, } from 'semantic-ui-react'; -import {Source} from '../util/gedcom_util'; +import {Source} from '../../util/gedcom_util'; import {AdditionalFiles, FileEntry} from './additional-files'; import {MultilineText} from './multiline-text'; import {Sources} from './sources'; diff --git a/src/details/events.tsx b/src/sidepanel/details/events.tsx similarity index 98% rename from src/details/events.tsx rename to src/sidepanel/details/events.tsx index e4d3b0c..9eabf9b 100644 --- a/src/details/events.tsx +++ b/src/sidepanel/details/events.tsx @@ -5,8 +5,8 @@ import {FormattedMessage, IntlShape, useIntl} from 'react-intl'; import {Link, useLocation} from 'react-router'; import {Header, Item} from 'semantic-ui-react'; import {DateOrRange, getDate} from 'topola'; -import {calcAge} from '../util/age_util'; -import {compareDates, formatDateOrRange} from '../util/date_util'; +import {calcAge} from '../../util/age_util'; +import {compareDates, formatDateOrRange} from '../../util/date_util'; import { dereference, GedcomData, @@ -20,7 +20,7 @@ import { resolveDate, resolveType, Source, -} from '../util/gedcom_util'; +} from '../../util/gedcom_util'; import {FileEntry} from './additional-files'; import {EventExtras, Image} from './event-extras'; import {TranslatedTag} from './translated-tag'; diff --git a/src/details/multiline-text.tsx b/src/sidepanel/details/multiline-text.tsx similarity index 100% rename from src/details/multiline-text.tsx rename to src/sidepanel/details/multiline-text.tsx diff --git a/src/details/sources.tsx b/src/sidepanel/details/sources.tsx similarity index 91% rename from src/details/sources.tsx rename to src/sidepanel/details/sources.tsx index 3f81ea3..c563d3b 100644 --- a/src/details/sources.tsx +++ b/src/sidepanel/details/sources.tsx @@ -1,8 +1,8 @@ import {useIntl} from 'react-intl'; import Linkify from 'react-linkify'; import {List} from 'semantic-ui-react'; -import {formatDateOrRange} from '../util/date_util'; -import {Source} from '../util/gedcom_util'; +import {formatDateOrRange} from '../../util/date_util'; +import {Source} from '../../util/gedcom_util'; interface Props { sources?: Source[]; diff --git a/src/details/translated-tag.tsx b/src/sidepanel/details/translated-tag.tsx similarity index 100% rename from src/details/translated-tag.tsx rename to src/sidepanel/details/translated-tag.tsx diff --git a/src/details/wrapped-image.tsx b/src/sidepanel/details/wrapped-image.tsx similarity index 100% rename from src/details/wrapped-image.tsx rename to src/sidepanel/details/wrapped-image.tsx diff --git a/src/sidepanel/side-panel.tsx b/src/sidepanel/side-panel.tsx new file mode 100644 index 0000000..28a96e9 --- /dev/null +++ b/src/sidepanel/side-panel.tsx @@ -0,0 +1,63 @@ +import {useIntl} from 'react-intl'; +import {Button, Icon, Sidebar, Tab} from 'semantic-ui-react'; +import {TopolaData} from '../util/gedcom_util'; +import {Config, ConfigPanel} from './config/config'; +import {CollapsedDetails} from './details/collapsed-details'; +import {Details} from './details/details'; + +interface SidePanelProps { + data: TopolaData; + selectedIndiId: string; + config: Config; + onConfigChange: (config: Config) => void; + expanded: boolean; + onToggle: () => void; +} + +export function SidePanel({ + data, + selectedIndiId, + config, + onConfigChange, + expanded, + onToggle, +}: SidePanelProps) { + const intl = useIntl(); + + const tabs = [ + { + menuItem: intl.formatMessage({ + id: 'tab.info', + defaultMessage: 'Info', + }), + render: () =>
, + }, + { + menuItem: intl.formatMessage({ + id: 'tab.settings', + defaultMessage: 'Settings', + }), + render: () => , + }, + ]; + + return ( + + {expanded ? ( + + ) : ( + + )} + + + ); +}