diff --git a/src/app.tsx b/src/app.tsx index db6a6b0..ca0f580 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -754,6 +754,8 @@ export function App() { colors={config.color} hideIds={config.id} hideSex={config.sex} + placeDisplay={config.place} + placeCount={config.placeCount} /> ); } diff --git a/src/chart.tsx b/src/chart.tsx index 727b5ff..db1b433 100644 --- a/src/chart.tsx +++ b/src/chart.tsx @@ -10,7 +10,7 @@ import { zoomTransform, } from 'd3-zoom'; import {saveAs} from 'file-saver'; -import {useEffect, useRef} from 'react'; +import {useEffect, useMemo, useRef} from 'react'; import {IntlShape, useIntl} from 'react-intl'; import { ChartHandle, @@ -25,8 +25,9 @@ import { RelativesChart, ChartColors as TopolaChartColors, } from 'topola'; -import {ChartColors, Ids, Sex} from './sidepanel/config/config'; +import {ChartColors, Ids, PlaceDisplay, Sex} from './sidepanel/config/config'; import {Media} from './util/media'; +import {DEFAULT_PLACE_DISPLAY_COUNT, shortenPlace} from './util/place_util'; import {usePrevious} from './util/previous-hook'; /** How much to zoom when using the +/- buttons. */ @@ -325,6 +326,8 @@ export interface ChartProps { colors?: ChartColors; hideIds?: Ids; hideSex?: Sex; + placeDisplay?: PlaceDisplay; + placeCount?: number; } class ChartWrapper { @@ -495,13 +498,52 @@ export function Chart(props: ChartProps) { const prevProps = usePrevious(props); const intl = useIntl(); + const placeDisplay = props.placeDisplay ?? PlaceDisplay.FULL; + const placeCount = props.placeCount ?? DEFAULT_PLACE_DISPLAY_COUNT; + + const processedData = useMemo(() => { + if (placeDisplay === PlaceDisplay.FULL) { + return props.data; + } + return { + ...props.data, + indis: props.data.indis.map((indi) => ({ + ...indi, + birth: indi.birth + ? { + ...indi.birth, + place: shortenPlace(indi.birth.place, placeDisplay, placeCount), + } + : undefined, + death: indi.death + ? { + ...indi.death, + place: shortenPlace(indi.death.place, placeDisplay, placeCount), + } + : undefined, + })), + fams: props.data.fams.map((fam) => ({ + ...fam, + marriage: fam.marriage + ? { + ...fam.marriage, + place: shortenPlace(fam.marriage.place, placeDisplay, placeCount), + } + : undefined, + })), + }; + }, [props.data, placeDisplay, placeCount]); + useEffect(() => { + const propsWithProcessedData: ChartProps = {...props, data: processedData}; if (prevProps) { const initialRender = props.chartType !== prevProps?.chartType || props.colors !== prevProps?.colors || props.hideIds !== prevProps?.hideIds || - props.hideSex !== prevProps?.hideSex; + props.hideSex !== prevProps?.hideSex || + props.placeDisplay !== prevProps?.placeDisplay || + props.placeCount !== prevProps?.placeCount; const resetPosition = props.chartType !== prevProps?.chartType || props.data !== prevProps.data || @@ -510,12 +552,12 @@ export function Chart(props: ChartProps) { // Therefore, compare id and generation instead. props.selection.id !== prevProps.selection.id || props.selection.generation !== prevProps.selection.generation; - chartWrapper.current.renderChart(props, intl, { + chartWrapper.current.renderChart(propsWithProcessedData, intl, { initialRender, resetPosition, }); } else { - chartWrapper.current.renderChart(props, intl, { + chartWrapper.current.renderChart(propsWithProcessedData, intl, { initialRender: true, resetPosition: true, }); diff --git a/src/sidepanel/config/config.tsx b/src/sidepanel/config/config.tsx index 45c3f13..cda1622 100644 --- a/src/sidepanel/config/config.tsx +++ b/src/sidepanel/config/config.tsx @@ -1,9 +1,12 @@ import {ParsedQuery} from 'query-string'; import {FormattedMessage} from 'react-intl'; -import {Checkbox, Form, Header, Item} from 'semantic-ui-react'; +import {Checkbox, Form, Header, Input, Item} from 'semantic-ui-react'; import {GedcomData} from '../../util/gedcom_util'; +import {DEFAULT_PLACE_DISPLAY_COUNT, PlaceDisplay} from '../../util/place_util'; import {SourceHead} from '../head/head'; +export {PlaceDisplay}; + export enum ChartColors { NO_COLOR, COLOR_BY_SEX, @@ -24,12 +27,16 @@ export interface Config { color: ChartColors; id: Ids; sex: Sex; + place: PlaceDisplay; + placeCount: number; } export const DEFALUT_CONFIG: Config = { color: ChartColors.COLOR_BY_GENERATION, id: Ids.SHOW, sex: Sex.SHOW, + place: PlaceDisplay.FULL, + placeCount: DEFAULT_PLACE_DISPLAY_COUNT, }; const COLOR_ARG = new Map([ @@ -54,16 +61,27 @@ const SEX_ARG = new Map([ const SEX_ARG_INVERSE = new Map(); SEX_ARG.forEach((v, k) => SEX_ARG_INVERSE.set(v, k)); +const PLACE_ARG = new Map([ + ['f', PlaceDisplay.FULL], + ['s', PlaceDisplay.SHORT], + ['h', PlaceDisplay.HIDE], +]); +const PLACE_ARG_INVERSE = new Map(); +PLACE_ARG.forEach((v, k) => PLACE_ARG_INVERSE.set(v, k)); + export function argsToConfig(args: ParsedQuery): Config { const getParam = (name: string) => { const value = args[name]; return typeof value === 'string' ? value : undefined; }; + const placeCount = parseInt(getParam('pn') ?? '', 10); return { color: COLOR_ARG.get(getParam('c') ?? '') ?? DEFALUT_CONFIG.color, id: ID_ARG.get(getParam('i') ?? '') ?? DEFALUT_CONFIG.id, sex: SEX_ARG.get(getParam('s') ?? '') ?? DEFALUT_CONFIG.sex, + place: PLACE_ARG.get(getParam('p') ?? '') ?? DEFALUT_CONFIG.place, + placeCount: placeCount >= 1 ? placeCount : DEFALUT_CONFIG.placeCount, }; } @@ -81,6 +99,16 @@ export function configToArgs(config: Config): ParsedQuery { if (sex) { result.s = sex; } + const place = PLACE_ARG_INVERSE.get(config.place); + if (place && config.place !== PlaceDisplay.FULL) { + result.p = place; + } + if ( + config.place === PlaceDisplay.SHORT && + config.placeCount !== DEFALUT_CONFIG.placeCount + ) { + result.pn = String(config.placeCount); + } return result; } @@ -252,6 +280,83 @@ export function ConfigPanel(props: { + + +
+ +
+ + + } + name="checkboxRadioGroup" + value="full" + checked={props.config.place === PlaceDisplay.FULL} + onClick={() => + props.onChange({...props.config, place: PlaceDisplay.FULL}) + } + /> + + + + } + name="checkboxRadioGroup" + value="short" + checked={props.config.place === PlaceDisplay.SHORT} + onClick={() => + props.onChange({...props.config, place: PlaceDisplay.SHORT}) + } + /> + {props.config.place === PlaceDisplay.SHORT && ( + { + const n = parseInt(value, 10); + if (n >= 1) { + props.onChange({...props.config, placeCount: n}); + } + }} + /> + )} + + + + } + name="checkboxRadioGroup" + value="hide" + checked={props.config.place === PlaceDisplay.HIDE} + onClick={() => + props.onChange({...props.config, place: PlaceDisplay.HIDE}) + } + /> + +
+
diff --git a/src/translations/bg.json b/src/translations/bg.json index fbac724..b0b5be6 100644 --- a/src/translations/bg.json +++ b/src/translations/bg.json @@ -129,6 +129,10 @@ "config.sex": "Пол", "config.sex.HIDE": "Скриване", "config.sex.SHOW": "Показване", + "config.places": "Места", + "config.places.FULL": "пълно", + "config.places.SHORT": "съкратено", + "config.places.HIDE": "скриване", "head.source": "Източник на данни", "name.unknown_name": "Неизвестно име", "extras.images": "Изображение", diff --git a/src/translations/cs.json b/src/translations/cs.json index 6d1b0dd..f836f7e 100644 --- a/src/translations/cs.json +++ b/src/translations/cs.json @@ -129,6 +129,10 @@ "config.sex": "Pohlaví", "config.sex.HIDE": "skrýt", "config.sex.SHOW": "zobrazit", + "config.places": "Místa", + "config.places.FULL": "plný", + "config.places.SHORT": "zkrácený", + "config.places.HIDE": "skrýt", "head.source": "Zdroj dat", "name.unknown_name": "N.N.", "extras.images": "Obrázky", diff --git a/src/translations/de.json b/src/translations/de.json index 99add93..281a84e 100644 --- a/src/translations/de.json +++ b/src/translations/de.json @@ -129,6 +129,10 @@ "config.sex": "Geschlecht", "config.sex.HIDE": "verbergen", "config.sex.SHOW": "anzeigen", + "config.places": "Orte", + "config.places.FULL": "vollständig", + "config.places.SHORT": "gekürzt", + "config.places.HIDE": "verbergen", "head.source": "Datenquelle", "name.unknown_name": "N.N.", "extras.images": "Bilder", diff --git a/src/translations/fr.json b/src/translations/fr.json index 5cf1ae6..fdf64c7 100644 --- a/src/translations/fr.json +++ b/src/translations/fr.json @@ -129,6 +129,10 @@ "config.sex": "Sexe", "config.sex.HIDE": "cacher", "config.sex.SHOW": "afficher", + "config.places": "Lieux", + "config.places.FULL": "complet", + "config.places.SHORT": "abrégé", + "config.places.HIDE": "cacher", "head.source": "Source de données", "name.unknown_name": "?", "extras.images": "Images", diff --git a/src/translations/it.json b/src/translations/it.json index 3759a52..85743c8 100644 --- a/src/translations/it.json +++ b/src/translations/it.json @@ -129,6 +129,10 @@ "config.sex": "Sesso", "config.sex.HIDE": "nascondere", "config.sex.SHOW": "visualizzare", + "config.places": "Luoghi", + "config.places.FULL": "completo", + "config.places.SHORT": "abbreviato", + "config.places.HIDE": "nascondere", "head.source": "Origine dati", "name.unknown_name": "N.N.", "extras.images": "Immagini", diff --git a/src/translations/pl.json b/src/translations/pl.json index 046ef60..59aef69 100644 --- a/src/translations/pl.json +++ b/src/translations/pl.json @@ -129,6 +129,10 @@ "config.sex": "Płeć", "config.sex.HIDE": "ukryj", "config.sex.SHOW": "pokaż", + "config.places": "Miejsca", + "config.places.FULL": "pełne", + "config.places.SHORT": "skrócone", + "config.places.HIDE": "ukryj", "head.source": "Źródło danych", "name.unknown_name": "N.N.", "extras.images": "Zdjęcia", diff --git a/src/translations/ru.json b/src/translations/ru.json index 68a4aaf..a50c70f 100644 --- a/src/translations/ru.json +++ b/src/translations/ru.json @@ -129,6 +129,10 @@ "config.sex": "Пол", "config.sex.HIDE": "Скрыть", "config.sex.SHOW": "Показать", + "config.places": "Места", + "config.places.FULL": "полное", + "config.places.SHORT": "сокращённое", + "config.places.HIDE": "скрыть", "head.source": "Источник данных", "name.unknown_name": "Неизвестно", "extras.images": "Картинки", diff --git a/src/util/place_util.spec.ts b/src/util/place_util.spec.ts new file mode 100644 index 0000000..c0b654a --- /dev/null +++ b/src/util/place_util.spec.ts @@ -0,0 +1,50 @@ +import {describe, expect, it} from '@jest/globals'; +import {PlaceDisplay, shortenPlace} from './place_util'; + +const LONG = 'Cyclone, Keating Township, McKean, Pennsylvania, United States'; + +describe('shortenPlace', () => { + it('returns full place unchanged in FULL mode', () => { + expect(shortenPlace(LONG, PlaceDisplay.FULL)).toBe(LONG); + }); + + it('returns undefined in HIDE mode', () => { + expect(shortenPlace(LONG, PlaceDisplay.HIDE)).toBeUndefined(); + }); + + it('defaults to 2 components in SHORT mode', () => { + expect(shortenPlace(LONG, PlaceDisplay.SHORT)).toBe( + 'Cyclone, Keating Township', + ); + }); + + it('keeps first N components when count is specified', () => { + expect(shortenPlace(LONG, PlaceDisplay.SHORT, 1)).toBe('Cyclone'); + expect(shortenPlace(LONG, PlaceDisplay.SHORT, 3)).toBe( + 'Cyclone, Keating Township, McKean', + ); + }); + + it('leaves place unchanged when it has fewer parts than count', () => { + expect(shortenPlace('Berlin, Germany', PlaceDisplay.SHORT, 3)).toBe( + 'Berlin, Germany', + ); + }); + + it('leaves single-component place unchanged in SHORT mode', () => { + expect(shortenPlace('Germany', PlaceDisplay.SHORT)).toBe('Germany'); + }); + + it('handles undefined place', () => { + expect(shortenPlace(undefined, PlaceDisplay.SHORT)).toBeUndefined(); + }); + + it('handles empty string', () => { + expect(shortenPlace('', PlaceDisplay.SHORT)).toBe(''); + }); + + it('is idempotent — applying SHORT twice with same count gives same result', () => { + const once = shortenPlace(LONG, PlaceDisplay.SHORT, 2); + expect(shortenPlace(once, PlaceDisplay.SHORT, 2)).toBe(once); + }); +}); diff --git a/src/util/place_util.ts b/src/util/place_util.ts new file mode 100644 index 0000000..0458a72 --- /dev/null +++ b/src/util/place_util.ts @@ -0,0 +1,36 @@ +export enum PlaceDisplay { + FULL, + SHORT, + HIDE, +} + +export const DEFAULT_PLACE_DISPLAY_COUNT = 2; + +/** + * Shortens a GEDCOM place string for display in chart nodes. + * + * GEDCOM places are comma-separated from most-specific to least-specific, + * e.g. "Cyclone, McKean, Pennsylvania, United States". + * SHORT keeps the first `count` components (most-specific). + * HIDE removes the place entirely. + */ +export function shortenPlace( + place: string | undefined, + mode: PlaceDisplay, + count: number = DEFAULT_PLACE_DISPLAY_COUNT, +): string | undefined { + if (!place || mode === PlaceDisplay.FULL) { + return place; + } + if (mode === PlaceDisplay.HIDE) { + return undefined; + } + const parts = place + .split(',') + .map((p) => p.trim()) + .filter(Boolean); + if (parts.length <= count) { + return place; + } + return parts.slice(0, count).join(', '); +}