Added search box

This commit is contained in:
Przemek Wiech 2019-03-24 18:11:23 +01:00
parent 120e4effbb
commit 9eecc7a0fd
9 changed files with 304 additions and 93 deletions

View File

@ -6,10 +6,13 @@
"array.prototype.flatmap": "^1.2.1",
"canvas-toBlob": "^1.0.0",
"d3": "^5.7.0",
"debounce": "^1.2.0",
"detect-browser": "^4.1.0",
"file-saver": "^2.0.1",
"history": "^4.7.2",
"javascript-natural-sort": "^0.7.1",
"jspdf": "^1.5.3",
"lunr": "^2.3.6",
"md5": "^2.2.1",
"parse-gedcom": "^1.0.5",
"query-string": "^5.1.1",
@ -25,13 +28,15 @@
"devDependencies": {
"@types/array.prototype.flatmap": "^1.2.0",
"@types/d3": "^5.5.0",
"@types/debounce": "^1.2.0",
"@types/file-saver": "^2.0.0",
"@types/history": "^4.7.2",
"@types/jest": "*",
"@types/jspdf": "^1.2.2",
"@types/lunr": "^2.3.2",
"@types/md5": "^2.1.33",
"@types/node": "*",
"@types/query-string": "^6.2.0",
"@types/query-string": "6.2.0",
"@types/react": "*",
"@types/react-dom": "*",
"@types/react-intl": "^2.3.15",

View File

@ -190,6 +190,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
render={(props: RouteComponentProps) => (
<TopBar
{...props}
gedcom={this.state.data && this.state.data.gedcom}
showingChart={
!!(
this.props.history.location.pathname === '/view' &&
@ -197,6 +198,7 @@ export class App extends React.Component<RouteComponentProps, {}> {
this.state.selection
)
}
onSelection={this.onSelection}
onPrint={() => {
analyticsEvent('print');
this.chartRef && this.chartRef.print();

92
src/date_util.ts Normal file
View File

@ -0,0 +1,92 @@
import {Date as TopolaDate, DateRange, getDate} from 'topola';
import {InjectedIntl} from 'react-intl';
const DATE_QUALIFIERS = new Map([
['abt', 'about'],
['cal', 'calculated'],
['est', 'estimated'],
]);
function formatDate(date: TopolaDate, intl: InjectedIntl) {
const hasDay = date.day !== undefined;
const hasMonth = date.month !== undefined;
const hasYear = date.year !== undefined;
if (!hasDay && !hasMonth && !hasYear) {
return date.text || '';
}
const dateObject = new Date(
hasYear ? date.year! : 0,
hasMonth ? date.month! - 1 : 0,
hasDay ? date.day! : 1,
);
const qualifier = date.qualifier && date.qualifier.toLowerCase();
const translatedQualifier =
qualifier &&
intl.formatMessage({
id: `date.${qualifier}`,
defaultMessage: DATE_QUALIFIERS.get(qualifier) || qualifier,
});
const formatOptions = {
day: hasDay ? 'numeric' : undefined,
month: hasMonth ? 'long' : undefined,
year: hasYear ? 'numeric' : undefined,
};
const translatedDate = new Intl.DateTimeFormat(
intl.locale,
formatOptions,
).format(dateObject);
return [translatedQualifier, translatedDate].join(' ');
}
function formatDateRage(dateRange: DateRange, intl: InjectedIntl) {
const fromDate = dateRange.from;
const toDate = dateRange.to;
const translatedFromDate = fromDate && formatDate(fromDate, intl);
const translatedToDate = toDate && formatDate(toDate, intl);
if (translatedFromDate && translatedToDate) {
return intl.formatMessage(
{
id: 'date.between',
defaultMessage: 'between {from} and {to}',
},
{from: translatedFromDate, to: translatedToDate},
);
}
if (translatedFromDate) {
return intl.formatMessage(
{
id: 'date.after',
defaultMessage: 'after {from}',
},
{from: translatedFromDate},
);
}
if (translatedToDate) {
return intl.formatMessage(
{
id: 'date.before',
defaultMessage: 'before {to}',
},
{to: translatedToDate},
);
}
return '';
}
/** Formats a date given in GEDCOM format. */
export function translateDate(gedcomDate: string, intl: InjectedIntl) {
const dateOrRange = getDate(gedcomDate);
if (!dateOrRange) {
return '';
}
if (dateOrRange.date) {
return formatDate(dateOrRange.date, intl);
}
if (dateOrRange.dateRange) {
return formatDateRage(dateOrRange.dateRange, intl);
}
return '';
}

View File

@ -1,11 +1,11 @@
import * as React from 'react';
import flatMap from 'array.prototype.flatmap';
import Linkify from 'react-linkify';
import {Date as TopolaDate, DateRange, getDate} from 'topola';
import {FormattedMessage, InjectedIntl} from 'react-intl';
import {GedcomData} from './gedcom_util';
import {GedcomEntry} from 'parse-gedcom';
import {intlShape} from 'react-intl';
import {translateDate} from './date_util';
interface Props {
gedcom: GedcomData;
@ -35,95 +35,6 @@ function translateTag(tag: string) {
);
}
const DATE_QUALIFIERS = new Map([
['abt', 'about'],
['cal', 'calculated'],
['est', 'estimated'],
]);
function formatDate(date: TopolaDate, intl: InjectedIntl) {
const hasDay = date.day !== undefined;
const hasMonth = date.month !== undefined;
const hasYear = date.year !== undefined;
if (!hasDay && !hasMonth && !hasYear) {
return date.text || '';
}
const dateObject = new Date(
hasYear ? date.year! : 0,
hasMonth ? date.month! - 1 : 0,
hasDay ? date.day! : 1,
);
const qualifier = date.qualifier && date.qualifier.toLowerCase();
const translatedQualifier =
qualifier &&
intl.formatMessage({
id: `date.${qualifier}`,
defaultMessage: DATE_QUALIFIERS.get(qualifier) || qualifier,
});
const formatOptions = {
day: hasDay ? 'numeric' : undefined,
month: hasMonth ? 'long' : undefined,
year: hasYear ? 'numeric' : undefined,
};
const translatedDate = new Intl.DateTimeFormat(
intl.locale,
formatOptions,
).format(dateObject);
return [translatedQualifier, translatedDate].join(' ');
}
function formatDateRage(dateRange: DateRange, intl: InjectedIntl) {
const fromDate = dateRange.from;
const toDate = dateRange.to;
const translatedFromDate = fromDate && formatDate(fromDate, intl);
const translatedToDate = toDate && formatDate(toDate, intl);
if (translatedFromDate && translatedToDate) {
return intl.formatMessage(
{
id: 'date.between',
defaultMessage: 'between {from} and {to}',
},
{from: translatedFromDate, to: translatedToDate},
);
}
if (translatedFromDate) {
return intl.formatMessage(
{
id: 'date.after',
defaultMessage: 'after {from}',
},
{from: translatedFromDate},
);
}
if (translatedToDate) {
return intl.formatMessage(
{
id: 'date.before',
defaultMessage: 'before {to}',
},
{to: translatedToDate},
);
}
return '';
}
function translateDate(gedcomDate: string, intl: InjectedIntl) {
const dateOrRange = getDate(gedcomDate);
if (!dateOrRange) {
return '';
}
if (dateOrRange.date) {
return formatDate(dateOrRange.date, intl);
}
if (dateOrRange.dateRange) {
return formatDateRage(dateOrRange.dateRange, intl);
}
return '';
}
function joinLines(lines: (JSX.Element | string)[]) {
return (
<>

4
src/javascript-natural-sort.d.ts vendored Normal file
View File

@ -0,0 +1,4 @@
// Data type definitions for the javascript-natural-sort library.
declare module 'javascript-natural-sort' {
export default function naturalSort(a: string, b: string): number;
}

71
src/search_index.ts Normal file
View File

@ -0,0 +1,71 @@
import naturalSort from 'javascript-natural-sort';
import lunr from 'lunr';
import {GedcomData} from './gedcom_util';
import {GedcomEntry} from 'parse-gedcom';
const MAX_RESULTS = 8;
export interface SearchResult {
id: string;
indi: GedcomEntry;
}
export interface SearchIndex {
search(input: string): SearchResult[];
}
/** Removes accents from letters, e.g. ó->o, ę->e. */
function normalize(input: string) {
return input
.toLocaleLowerCase()
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.replace(/\u0142/g, 'l'); // Special case: ł is not affected by NFD.
}
/** Comparator to sort by score first, then by id. */
function compare(a: lunr.Index.Result, b: lunr.Index.Result) {
if (a.score !== b.score) {
return a.score - b.score;
}
return naturalSort(a.ref, b.ref);
}
class LunrSearchIndex implements SearchIndex {
private index: lunr.Index;
public constructor(private gedcom: GedcomData) {
this.index = lunr(function() {
this.ref('id');
this.field('id');
this.field('name');
this.field('normalizedName');
for (let id in gedcom.indis) {
const name = gedcom.indis[id].tree
.filter((entry) => entry.tag === 'NAME')
.map((entry) => entry.data)
.join(' ');
this.add({id, name, normalizedName: normalize(name)});
}
});
}
public search(input: string) {
const query = input
.split(' ')
.filter((s) => !!s)
.map((s) => `+${s}*`)
.join(' ');
const results = this.index.search(query);
return results
.sort(compare)
.slice(0, MAX_RESULTS)
.map((result) => ({id: result.ref, indi: this.gedcom.indis[result.ref]}));
}
}
/** Builds a search index from data. */
export function buildSearchIndex(gedcom: GedcomData): SearchIndex {
return new LunrSearchIndex(gedcom);
}

48
src/search_util.tsx Normal file
View File

@ -0,0 +1,48 @@
import * as React from 'react';
import {GedcomEntry} from 'parse-gedcom';
import {InjectedIntl} from 'react-intl';
import {SearchResult} from './search_index';
import {translateDate} from './date_util';
function getNameLine(result: SearchResult) {
const nameTag = result.indi.tree.find((entry) => entry.tag === 'NAME');
const name =
nameTag &&
nameTag.data
.split('/')
.filter((s) => !!s)
.join(' ');
if (result.id.length > 8) {
return name;
}
return (
<>
{name} <i>({result.id})</i>
</>
);
}
function getDate(indi: GedcomEntry, tag: string, intl: InjectedIntl) {
const eventEntry = indi.tree.find((entry) => entry.tag === tag);
const dateEntry =
eventEntry && eventEntry.tree.find((entry) => entry.tag === 'DATE');
return (dateEntry && translateDate(dateEntry.data, intl)) || '';
}
function getDescriptionLine(indi: GedcomEntry, intl: InjectedIntl) {
const birthDate = getDate(indi, 'BIRT', intl);
const deathDate = getDate(indi, 'DEAT', intl);
if (!deathDate) {
return birthDate;
}
return `${birthDate} ${deathDate}`;
}
/** Produces an object that is displayed in the Semantic UI Search results. */
export function displaySearchResult(result: SearchResult, intl: InjectedIntl) {
return {
id: result.id,
title: getNameLine(result),
description: getDescriptionLine(result.indi, intl),
};
}

View File

@ -1,8 +1,13 @@
import * as queryString from 'query-string';
import * as React from 'react';
import debounce from 'debounce';
import md5 from 'md5';
import {analyticsEvent} from './analytics';
import {FormattedMessage} from 'react-intl';
import {buildSearchIndex, SearchIndex, SearchResult} from './search_index';
import {displaySearchResult} from './search_util';
import {FormattedMessage, intlShape} from 'react-intl';
import {GedcomData} from './gedcom_util';
import {IndiInfo} from 'topola';
import {Link} from 'react-router-dom';
import {RouteComponentProps} from 'react-router-dom';
import {
@ -14,16 +19,21 @@ import {
Input,
Form,
Dropdown,
Search,
SearchProps,
} from 'semantic-ui-react';
/** Menus and dialogs state. */
interface State {
loadUrlDialogOpen: boolean;
url?: string;
searchResults: SearchResult[];
}
interface Props {
showingChart: boolean;
gedcom?: GedcomData;
onSelection: (indiInfo: IndiInfo) => void;
onPrint: () => void;
onDownloadPdf: () => void;
onDownloadPng: () => void;
@ -49,8 +59,13 @@ export class TopBar extends React.Component<
RouteComponentProps & Props,
State
> {
state: State = {loadUrlDialogOpen: false};
state: State = {
loadUrlDialogOpen: false,
searchResults: [],
};
inputRef?: Input;
searchRef?: {setValue(value: string): void};
searchIndex?: SearchIndex;
/** Handles the "Upload file" button. */
async handleUpload(event: React.SyntheticEvent<HTMLInputElement>) {
@ -135,6 +150,44 @@ export class TopBar extends React.Component<
);
}
/** On search input change. */
handleSearch(input: string | undefined) {
if (!input) {
return;
}
const results = this.searchIndex!.search(input).map((result) =>
displaySearchResult(result, this.context.intl),
);
this.setState(Object.assign({}, this.state, {searchResults: results}));
}
/** On search result selected. */
handleResultSelect(id: string) {
this.props.onSelection({id, generation: 0});
this.searchRef!.setValue('');
}
initializeSearchIndex() {
if (this.props.gedcom) {
this.searchIndex = buildSearchIndex(this.props.gedcom);
}
}
componentDidMount() {
this.initializeSearchIndex();
}
componentDidUpdate(prevProps: Props) {
if (prevProps.gedcom !== this.props.gedcom) {
this.initializeSearchIndex();
}
}
/** Make intl appear in this.context. */
static contextTypes = {
intl: intlShape,
};
render() {
const loadFromUrlModal = (
<Modal
@ -216,6 +269,29 @@ export class TopBar extends React.Component<
</Dropdown.Item>
</Dropdown.Menu>
</Dropdown>
<Search
onSearchChange={debounce(
(_: React.MouseEvent<HTMLElement>, data: SearchProps) =>
this.handleSearch(data.value),
200,
)}
onResultSelect={(_, data) => this.handleResultSelect(data.result.id)}
results={this.state.searchResults}
noResultsMessage={this.context.intl.formatMessage({
id: 'menu.search.no_results',
defaultMessage: 'No results found',
})}
placeholder={this.context.intl.formatMessage({
id: 'menu.search.placeholder',
defaultMessage: 'Search for people',
})}
selectFirstResult={true}
ref={(ref) =>
(this.searchRef = (ref as unknown) as {
setValue(value: string): void;
})
}
/>
</>
) : null;

View File

@ -7,6 +7,8 @@
"menu.png_file": "Plik PNG",
"menu.svg_file": "Plik SVG",
"menu.github": "Źródła na GitHub",
"menu.search.placeholder": "Szukaj osoby",
"menu.search.no_results": "Brak wyników",
"intro.title": "Topola Genealogy",
"intro.description": "Topola Genealogy pozwala przeglądać drzewo genealogiczne w interaktywny sposób.",
"intro.instructions": "Kliknij OTWÓRZ URL lub OTWÓRZ PLIK, aby załadować plik GEDCOM. Większość programów genealogicznych posiada funkcję eksportu do pliku GEDCOM.",