Improvements for events in info panel (#72)

* Improved visuals of events panel, handle family events

* Create separate package for details panel

* Improved handling of multiple event notes
This commit is contained in:
czifumasa
2021-10-26 12:49:18 +02:00
committed by GitHub
parent c55c71b746
commit f3d98338cb
11 changed files with 539 additions and 267 deletions

View File

@@ -11,7 +11,7 @@ import {
DEFALUT_CONFIG,
} from './config';
import {DataSourceEnum, SourceSelection} from './datasource/data_source';
import {Details} from './details';
import {Details} from './details/details';
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
import {getI18nMessage} from './util/error_i18n';

View File

@@ -1,235 +0,0 @@
import * as React from 'react';
import flatMap from 'array.prototype.flatmap';
import Linkify from 'react-linkify';
import {
FormattedMessage,
injectIntl,
IntlShape,
WrappedComponentProps,
} from 'react-intl';
import {GedcomData, pointerToId} from './util/gedcom_util';
import {GedcomEntry} from 'parse-gedcom';
import {translateDate} from './util/date_util';
interface Props {
gedcom: GedcomData;
indi: string;
}
const EVENT_TAGS = ['BIRT', 'BAPM', 'CHR', 'EVEN', 'CENS', 'DEAT', 'BURI'];
const EXCLUDED_TAGS = ['NAME', 'SEX', 'FAMC', 'FAMS', 'NOTE', 'SOUR'];
const TAG_DESCRIPTIONS = new Map([
['ADOP', 'Adoption'],
['BAPM', 'Baptism'],
['BIRT', 'Birth'],
['BURI', 'Burial'],
['CENS', 'Census'],
['CHR', 'Christening'],
['CREM', 'Cremation'],
['DEAT', 'Death'],
['EDUC', 'Education'],
['EMAIL', 'E-mail'],
['EMIG', 'Emigration'],
['EVEN', 'Event'],
['FACT', 'Fact'],
['IMMI', 'Immigration'],
['MARR', 'Marriage'],
['MILT', 'Military services'],
['NATU', 'Naturalization'],
['OCCU', 'Occupation'],
['TITL', 'Title'],
['WWW', 'WWW'],
]);
function translateTag(tag: string) {
const normalizedTag = tag.replace(/_/g, '');
return (
<FormattedMessage
id={`gedcom.${normalizedTag}`}
defaultMessage={TAG_DESCRIPTIONS.get(normalizedTag) || normalizedTag}
/>
);
}
function joinLines(lines: (JSX.Element | string)[]) {
return (
<>
{lines.map((line, index) => (
<div key={index}>
<Linkify properties={{target: '_blank'}}>{line}</Linkify>
<br />
</div>
))}
</>
);
}
/**
* Returns the data for the given GEDCOM entry as an array of lines. Supports
* continuations with CONT and CONC.
*/
function getData(entry: GedcomEntry) {
const result = [entry.data];
entry.tree.forEach((subentry) => {
if (subentry.tag === 'CONC' && subentry.data) {
const last = result.length - 1;
result[last] += subentry.data;
} else if (subentry.tag === 'CONT' && subentry.data) {
result.push(subentry.data);
}
});
return result;
}
function eventDetails(entry: GedcomEntry, intl: IntlShape) {
const lines = [];
if (entry.data && entry.data.length > 1) {
lines.push(<i>{entry.data}</i>);
}
const date = entry.tree.find((subentry) => subentry.tag === 'DATE');
if (date && date.data) {
lines.push(translateDate(date.data, intl));
}
const place = entry.tree.find((subentry) => subentry.tag === 'PLAC');
if (place && place.data) {
lines.push(...getData(place));
}
entry.tree
.filter((subentry) => subentry.tag === 'NOTE')
.forEach((note) =>
getData(note).forEach((line) => lines.push(<i>{line}</i>)),
);
if (!lines.length) {
return null;
}
return (
<>
<div className="ui sub header">{translateTag(entry.tag)}</div>
<span>{joinLines(lines)}</span>
</>
);
}
function dataDetails(entry: GedcomEntry) {
const lines = [];
if (entry.data) {
lines.push(...getData(entry));
}
entry.tree
.filter((subentry) => subentry.tag === 'NOTE')
.forEach((note) =>
getData(note).forEach((line) => lines.push(<i>{line}</i>)),
);
if (!lines.length) {
return null;
}
return (
<>
<div className="ui sub header">{translateTag(entry.tag)}</div>
<span>{joinLines(lines)}</span>
</>
);
}
function noteDetails(entry: GedcomEntry) {
return joinLines(
getData(entry).map((line, index) => <i key={index}>{line}</i>),
);
}
function nameDetails(entry: GedcomEntry) {
return (
<h2 className="ui header">
{entry.data
.split('/')
.filter((name) => !!name)
.map((name, index) => (
<div key={index}>
{name}
<br />
</div>
))}
</h2>
);
}
function getDetails(
entries: GedcomEntry[],
tags: string[],
detailsFunction: (entry: GedcomEntry) => JSX.Element | null,
): JSX.Element[] {
return flatMap(tags, (tag) =>
entries
.filter((entry) => entry.tag === tag)
.map((entry) => detailsFunction(entry)),
)
.filter((element) => element !== null)
.map((element, index) => (
<div className="ui segment" key={index}>
{element}
</div>
));
}
/**
* Returns true if there is displayable information in this entry.
* Returns false if there is no data in this entry or this is only a reference
* to another entry.
*/
function hasData(entry: GedcomEntry) {
return entry.tree.length > 0 || (entry.data && !entry.data.startsWith('@'));
}
function getOtherDetails(entries: GedcomEntry[]) {
return entries
.filter(
(entry) =>
!EXCLUDED_TAGS.includes(entry.tag) && !EVENT_TAGS.includes(entry.tag),
)
.filter(hasData)
.map((entry) => dataDetails(entry))
.filter((element) => element !== null)
.map((element, index) => (
<div className="ui segment" key={index}>
{element}
</div>
));
}
/**
* If the entry is a reference to a top-level entry, the referenced entry is
* returned. Otherwise, returns the given entry unmodified.
*/
function dereference(entry: GedcomEntry, gedcom: GedcomData) {
if (entry.data) {
const dereferenced = gedcom.other[pointerToId(entry.data)];
if (dereferenced) {
return dereferenced;
}
}
return entry;
}
class DetailsComponent extends React.Component<
Props & WrappedComponentProps,
{}
> {
render() {
const entries = this.props.gedcom.indis[this.props.indi].tree;
const entriesWithData = entries
.map((entry) => dereference(entry, this.props.gedcom))
.filter(hasData);
return (
<div className="ui segments details">
{getDetails(entries, ['NAME'], nameDetails)}
{getDetails(entries, EVENT_TAGS, (entry) =>
eventDetails(entry, this.props.intl),
)}
{getOtherDetails(entriesWithData)}
{getDetails(entriesWithData, ['NOTE'], noteDetails)}
</div>
);
}
}
export const Details = injectIntl(DetailsComponent);

148
src/details/details.tsx Normal file
View File

@@ -0,0 +1,148 @@
import * as React from 'react';
import flatMap from 'array.prototype.flatmap';
import {injectIntl, WrappedComponentProps} from 'react-intl';
import {dereference, GedcomData, getData} from '../util/gedcom_util';
import {GedcomEntry} from 'parse-gedcom';
import {TranslatedTag} from './translated-tag';
import {Events} from './events';
import {MultilineText} from './multiline-text';
interface Props {
gedcom: GedcomData;
indi: string;
}
const EXCLUDED_TAGS = [
'BIRT',
'BAPM',
'CHR',
'EVEN',
'CENS',
'DEAT',
'BURI',
'NAME',
'SEX',
'FAMC',
'FAMS',
'NOTE',
'SOUR',
];
function dataDetails(entry: GedcomEntry) {
const lines = [];
if (entry.data) {
lines.push(...getData(entry));
}
entry.tree
.filter((subentry) => subentry.tag === 'NOTE')
.forEach((note) =>
getData(note).forEach((line) => lines.push(<i>{line}</i>)),
);
if (!lines.length) {
return null;
}
return (
<>
<div className="ui sub header">
<TranslatedTag tag={entry.tag} />
</div>
<span>
<MultilineText lines={lines} />
</span>
</>
);
}
function noteDetails(entry: GedcomEntry) {
return (
<MultilineText
lines={getData(entry).map((line, index) => (
<i key={index}>{line}</i>
))}
/>
);
}
function nameDetails(entry: GedcomEntry) {
return (
<h2 className="ui header">
{entry.data
.split('/')
.filter((name) => !!name)
.map((name, index) => (
<div key={index}>
{name}
<br />
</div>
))}
</h2>
);
}
function getDetails(
entries: GedcomEntry[],
tags: string[],
detailsFunction: (entry: GedcomEntry) => JSX.Element | null,
): JSX.Element[] {
return flatMap(tags, (tag) =>
entries
.filter((entry) => entry.tag === tag)
.map((entry) => detailsFunction(entry)),
)
.filter((element) => element !== null)
.map((element, index) => (
<div className="ui segment" key={index}>
{element}
</div>
));
}
/**
* Returns true if there is displayable information in this entry.
* Returns false if there is no data in this entry or this is only a reference
* to another entry.
*/
function hasData(entry: GedcomEntry) {
return entry.tree.length > 0 || (entry.data && !entry.data.startsWith('@'));
}
function getOtherDetails(entries: GedcomEntry[]) {
return entries
.filter((entry) => !EXCLUDED_TAGS.includes(entry.tag))
.filter(hasData)
.map((entry) => dataDetails(entry))
.filter((element) => element !== null)
.map((element, index) => (
<div className="ui segment" key={index}>
{element}
</div>
));
}
class DetailsComponent extends React.Component<
Props & WrappedComponentProps,
{}
> {
render() {
const entries = this.props.gedcom.indis[this.props.indi].tree;
const entriesWithData = entries
.map((entry) =>
dereference(entry, this.props.gedcom, (gedcom) => gedcom.other),
)
.filter(hasData);
return (
<div className="ui segments details">
{getDetails(entries, ['NAME'], nameDetails)}
<Events
gedcom={this.props.gedcom}
entries={entries}
indi={this.props.indi}
/>
{getOtherDetails(entriesWithData)}
{getDetails(entriesWithData, ['NOTE'], noteDetails)}
</div>
);
}
}
export const Details = injectIntl(DetailsComponent);

235
src/details/events.tsx Normal file
View File

@@ -0,0 +1,235 @@
import * as React from 'react';
import {injectIntl, IntlShape, WrappedComponentProps} from 'react-intl';
import {dereference, GedcomData, getData} from '../util/gedcom_util';
import {GedcomEntry} from 'parse-gedcom';
import {compareDates, translateDate} from '../util/date_util';
import {DateOrRange, getDate} from 'topola';
import {TranslatedTag} from './translated-tag';
import {MultilineText} from './multiline-text';
import flatMap from 'array.prototype.flatmap';
interface Props {
gedcom: GedcomData;
indi: string;
entries: GedcomEntry[];
}
interface Event {
type: string;
date: DateOrRange | undefined;
header: JSX.Element;
subHeader: JSX.Element | null;
place: JSX.Element | null;
notes: JSX.Element | null;
}
const EVENT_TAGS = [
'BIRT',
'BAPM',
'CHR',
'FAMS',
'EVEN',
'CENS',
'DEAT',
'BURI',
];
const FAMILY_EVENT_TAGS = ['MARR', 'DIV'];
function eventHeader(tag: string, date: GedcomEntry | null, intl: IntlShape) {
return (
<div>
<span style={{textTransform: 'uppercase'}} className="ui small header">
<TranslatedTag tag={tag} />
</span>
{date && date.data ? (
<span className="ui sub header right floated">
{translateDate(date.data, intl)}
</span>
) : null}
</div>
);
}
function eventFamilyDetails(
entry: GedcomEntry,
indi: string,
familyEntry: GedcomEntry,
gedcom: GedcomData,
) {
const spouseReference = familyEntry.tree
.filter((familySubEntry) => ['WIFE', 'HUSB'].includes(familySubEntry.tag))
.find((familySubEntry) => !familySubEntry.data.includes(indi));
if (spouseReference) {
const spouseName = dereference(
spouseReference,
gedcom,
(gedcom) => gedcom.indis,
)
.tree.filter((subEntry) => subEntry.tag === 'NAME')
.find(
(subEntry) =>
subEntry.tree.filter(
(nameEntry) =>
nameEntry.tag === 'TYPE' && nameEntry.data === 'married',
).length === 0,
);
if (spouseName) {
return <div className="meta">{spouseName.data.replaceAll('/', '')}</div>;
}
}
return null;
}
function eventPlace(entry: GedcomEntry) {
const place = entry.tree.find((subEntry) => subEntry.tag === 'PLAC');
if (place && place.data) {
return <div className="description">{getData(place)}</div>;
}
return null;
}
function eventNotes(entry: GedcomEntry, gedcom: GedcomData) {
const notes = entry.tree
.filter((subentry) => ['NOTE', 'TYPE'].includes(subentry.tag))
.map((note) => dereference(note, gedcom, (gedcom) => gedcom.other))
.map((note) => noteDetails(note));
if (notes && notes.length) {
return (
<div className="description">
{notes.map((note, index) => (
<div key={index}>{note}</div>
))}
</div>
);
}
return null;
}
function noteDetails(entry: GedcomEntry) {
return (
<MultilineText
lines={getData(entry).map((line, index) => (
<i key={index}>{line}</i>
))}
/>
);
}
function eventDetails(event: Event) {
return (
<div className="content">
{event.header}
{event.subHeader}
{event.place}
{event.notes}
</div>
);
}
function getEventDetails(
entries: GedcomEntry[],
gedcom: GedcomData,
indi: string,
intl: IntlShape,
): JSX.Element | null {
const events = flatMap(EVENT_TAGS, (tag) =>
entries
.filter((entry) => entry.tag === tag)
.map((eventEntry) => toEvent(eventEntry, gedcom, indi, intl))
.flatMap((events) => events)
.sort((event1, event2) => compareDates(event1.date, event2.date))
.map((event) => eventDetails(event)),
);
if (events && events.length) {
return (
<div className="ui segment divided items">
{events.map((eventElement, index) => (
<div className="ui attached item" key={index}>
{eventElement}
</div>
))}
</div>
);
}
return null;
}
function toEvent(
entry: GedcomEntry,
gedcom: GedcomData,
indi: string,
intl: IntlShape,
): Event[] {
if (entry.tag === 'FAMS') {
return toFamilyEvents(entry, gedcom, indi, intl);
}
return toIndiEvent(entry, gedcom, indi, intl);
}
function toIndiEvent(
entry: GedcomEntry,
gedcom: GedcomData,
indi: string,
intl: IntlShape,
): Event[] {
const date = resolveDate(entry) || null;
return [
{
date: date ? getDate(date.data) : undefined,
type: entry.tag,
header: eventHeader(entry.tag, date, intl),
subHeader: null,
place: eventPlace(entry),
notes: eventNotes(entry, gedcom),
},
];
}
function resolveDate(entry: GedcomEntry) {
return entry.tree.find((subEntry) => subEntry.tag === 'DATE');
}
function toFamilyEvents(
entry: GedcomEntry,
gedcom: GedcomData,
indi: string,
intl: IntlShape,
): Event[] {
const family = dereference(entry, gedcom, (gedcom) => gedcom.fams);
return flatMap(FAMILY_EVENT_TAGS, (tag) =>
family.tree.filter((entry) => entry.tag === tag),
).map((familyMarriageEvent) => {
const date = resolveDate(familyMarriageEvent) || null;
return {
date: date ? getDate(date.data) : undefined,
type: familyMarriageEvent.tag,
header: eventHeader(familyMarriageEvent.tag, date, intl),
subHeader: eventFamilyDetails(familyMarriageEvent, indi, family, gedcom),
place: eventPlace(familyMarriageEvent),
notes: eventNotes(familyMarriageEvent, gedcom),
};
});
}
class EventsComponent extends React.Component<
Props & WrappedComponentProps,
{}
> {
render() {
return (
<>
{getEventDetails(
this.props.entries,
this.props.gedcom,
this.props.indi,
this.props.intl,
)}
</>
);
}
}
export const Events = injectIntl(EventsComponent);

View File

@@ -0,0 +1,31 @@
import * as React from 'react';
import {injectIntl, WrappedComponentProps} from 'react-intl';
import Linkify from 'react-linkify';
interface Props {
lines: (JSX.Element | string)[];
}
function joinLines(lines: (JSX.Element | string)[]) {
return (
<>
{lines.map((line, index) => (
<div key={index}>
<Linkify properties={{target: '_blank'}}>{line}</Linkify>
<br />
</div>
))}
</>
);
}
class MultilineTextComponent extends React.Component<
Props & WrappedComponentProps,
{}
> {
render() {
return joinLines(this.props.lines);
}
}
export const MultilineText = injectIntl(MultilineTextComponent);

View File

@@ -0,0 +1,51 @@
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
import * as React from 'react';
interface Props {
tag: string;
}
const TAG_DESCRIPTIONS = new Map([
['ADOP', 'Adoption'],
['BAPM', 'Baptism'],
['BIRT', 'Birth'],
['BURI', 'Burial'],
['CENS', 'Census'],
['CHR', 'Christening'],
['CREM', 'Cremation'],
['DEAT', 'Death'],
['EDUC', 'Education'],
['EMAIL', 'E-mail'],
['EMIG', 'Emigration'],
['EVEN', 'Event'],
['FACT', 'Fact'],
['IMMI', 'Immigration'],
['MARR', 'Marriage'],
['DIV', 'Divorce'],
['MILT', 'Military services'],
['NATU', 'Naturalization'],
['OCCU', 'Occupation'],
['TITL', 'Title'],
['WWW', 'WWW'],
]);
function translateTag(tag: string) {
const normalizedTag = tag.replace(/_/g, '');
return (
<FormattedMessage
id={`gedcom.${normalizedTag}`}
defaultMessage={TAG_DESCRIPTIONS.get(normalizedTag) || normalizedTag}
/>
);
}
class TranslatedTagComponent extends React.Component<
Props & WrappedComponentProps,
{}
> {
render() {
return translateTag(this.props.tag);
}
}
export const TranslatedTag = injectIntl(TranslatedTagComponent);

View File

@@ -54,6 +54,8 @@
"gedcom.TITL": "Tytuł",
"gedcom.WWW": "Strona WWW",
"gedcom._UPD": "Ostatnia aktualizacja",
"gedcom.MARR": "Małżeństwo",
"gedcom.DIV": "Rozwód",
"date.abt": "około",
"date.cal": "wyliczone",
"date.est": "oszacowane",

View File

@@ -97,3 +97,34 @@ export function formatDateOrRange(
export function translateDate(gedcomDate: string, intl: IntlShape): string {
return formatDateOrRange(getDate(gedcomDate), intl);
}
/** Compares a dates given in GEDCOM format. */
export function compareDates(
firstDateOrRange: DateOrRange | undefined,
secondDateOrRange: DateOrRange | undefined,
): number {
const date1 =
firstDateOrRange &&
(firstDateOrRange.date ||
(firstDateOrRange.dateRange && firstDateOrRange.dateRange.from));
const date2 =
secondDateOrRange &&
(secondDateOrRange.date ||
(secondDateOrRange.dateRange && secondDateOrRange.dateRange.from));
if (!date1 || !date1.year || !date2 || !date2.year) {
return 0;
}
if (date1.year !== date2.year) {
return date1.year - date2.year;
}
if (!date1.month || !date2.month) {
return 0;
}
if (date1.month !== date2.month) {
return date1.month - date2.month;
}
if (date1.day && date2.day && date1.day !== date2.day) {
return date1.month - date2.month;
}
return 0;
}

View File

@@ -1,13 +1,13 @@
import {GedcomEntry, parse as parseGedcom} from 'parse-gedcom';
import {TopolaError} from './error';
import {
gedcomEntriesToJson,
JsonFam,
JsonGedcomData,
JsonIndi,
gedcomEntriesToJson,
JsonImage,
JsonEvent,
JsonIndi,
} from 'topola';
import {compareDates} from './date_util';
export interface GedcomData {
/** The HEAD entry. */
@@ -76,33 +76,6 @@ function strcmp(a: string, b: string) {
return 0;
}
/** Compares dates of the given events. */
function compareDates(
event1: JsonEvent | undefined,
event2: JsonEvent | undefined,
): number {
const date1 =
event1 && (event1.date || (event1.dateRange && event1.dateRange.from));
const date2 =
event2 && (event2.date || (event2.dateRange && event2.dateRange.from));
if (!date1 || !date1.year || !date2 || !date2.year) {
return 0;
}
if (date1.year !== date2.year) {
return date1.year - date2.year;
}
if (!date1.month || !date2.month) {
return 0;
}
if (date1.month !== date2.month) {
return date1.month - date2.month;
}
if (date1.day && date2.day && date1.day !== date2.day) {
return date1.month - date2.month;
}
return 0;
}
/** Birth date comparator for individuals. */
function birthDatesComparator(gedcom: JsonGedcomData) {
const indiMap = idToIndiMap(gedcom);
@@ -179,6 +152,41 @@ function sortSpouses(gedcom: JsonGedcomData): JsonGedcomData {
return Object.assign({}, gedcom, {indis: newIndis});
}
/**
* If the entry is a reference to a top-level entry, the referenced entry is
* returned. Otherwise, returns the given entry unmodified.
*/
export function dereference(
entry: GedcomEntry,
gedcom: GedcomData,
getterFunction: (gedcom: GedcomData) => {[key: string]: GedcomEntry},
) {
if (entry.data) {
const dereferenced = getterFunction(gedcom)[pointerToId(entry.data)];
if (dereferenced) {
return dereferenced;
}
}
return entry;
}
/**
* Returns the data for the given GEDCOM entry as an array of lines. Supports
* continuations with CONT and CONC.
*/
export function getData(entry: GedcomEntry) {
const result = [entry.data];
entry.tree.forEach((subentry) => {
if (subentry.tag === 'CONC' && subentry.data) {
const last = result.length - 1;
result[last] += subentry.data;
} else if (subentry.tag === 'CONT' && subentry.data) {
result.push(subentry.data);
}
});
return result;
}
/** Sorts children and spouses. */
export function normalizeGedcom(gedcom: JsonGedcomData): JsonGedcomData {
return sortSpouses(sortChildren(gedcom));

View File

@@ -1,6 +1,6 @@
import {createMedia} from '@artsy/fresnel';
/** Defines the breakpoints at which to show different UI variants. */
/** Defines the breakpoints at which to show different UI variants.*/
const AppMedia = createMedia({
breakpoints: {
small: 320,