diff --git a/package-lock.json b/package-lock.json
index f2e5b3f0..4928224f 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -20,6 +20,7 @@
"gedcomx-js": "^2.8.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
+ "react-localization": "^1.0.19",
"react-router-dom": "^6.10.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
@@ -10576,6 +10577,11 @@
"node": ">=8.9.0"
}
},
+ "node_modules/localized-strings": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/localized-strings/-/localized-strings-0.2.4.tgz",
+ "integrity": "sha512-TKDhqFPkIIN/if2FSvVVZTaM/GP9TzfgdQ2uY65mr32xgFu5nqkKXprXbzy5rfx32DF5LDvS/y1UqYF/mAscYA=="
+ },
"node_modules/locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -12985,6 +12991,17 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
+ "node_modules/react-localization": {
+ "version": "1.0.19",
+ "resolved": "https://registry.npmjs.org/react-localization/-/react-localization-1.0.19.tgz",
+ "integrity": "sha512-f4E6T8xRis19K8qMOnnhjGV2quy1YH2lrSsnAiytvgt7uOSp6WgDhrZH6XZEaEFqupTOCFSf8uagIoIAkjl4JA==",
+ "dependencies": {
+ "localized-strings": "^0.2.0"
+ },
+ "peerDependencies": {
+ "react": "^18.0.0 || ^17.0.0 || ^16.0.0 || ^15.6.0"
+ }
+ },
"node_modules/react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
@@ -23435,6 +23452,11 @@
"json5": "^2.1.2"
}
},
+ "localized-strings": {
+ "version": "0.2.4",
+ "resolved": "https://registry.npmjs.org/localized-strings/-/localized-strings-0.2.4.tgz",
+ "integrity": "sha512-TKDhqFPkIIN/if2FSvVVZTaM/GP9TzfgdQ2uY65mr32xgFu5nqkKXprXbzy5rfx32DF5LDvS/y1UqYF/mAscYA=="
+ },
"locate-path": {
"version": "6.0.0",
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
@@ -25019,6 +25041,14 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz",
"integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="
},
+ "react-localization": {
+ "version": "1.0.19",
+ "resolved": "https://registry.npmjs.org/react-localization/-/react-localization-1.0.19.tgz",
+ "integrity": "sha512-f4E6T8xRis19K8qMOnnhjGV2quy1YH2lrSsnAiytvgt7uOSp6WgDhrZH6XZEaEFqupTOCFSf8uagIoIAkjl4JA==",
+ "requires": {
+ "localized-strings": "^0.2.0"
+ }
+ },
"react-refresh": {
"version": "0.11.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.11.0.tgz",
diff --git a/package.json b/package.json
index 20294ff9..d013a829 100644
--- a/package.json
+++ b/package.json
@@ -33,6 +33,7 @@
"gedcomx-js": "^2.8.0",
"react": "^18.1.0",
"react-dom": "^18.1.0",
+ "react-localization": "^1.0.19",
"react-router-dom": "^6.10.0",
"react-scripts": "5.0.1",
"typescript": "^4.9.5",
diff --git a/src/App.css b/src/App.css
index cb849bf2..6130ed7b 100644
--- a/src/App.css
+++ b/src/App.css
@@ -5,38 +5,81 @@
width: 100%;
margin: 0;
display: grid;
- gap: 0;
+ gap: 1rem;
justify-content: space-between;
grid-template-columns: 1fr;
+ overflow: auto;
- background: var(--background);
+ background: var(--background-lower);
font-family: "Nunito", sans-serif;
+
+ grid-template-areas:
+ "header"
+ "main"
+ "footer";
+ grid-template-rows: auto 2fr auto;
}
#root > * {
- width: 100%;
box-sizing: border-box;
}
main {
grid-area: main;
- background: var(--background-lower);
}
main > * {
background: var(--background);
margin: 1rem auto 0;
box-sizing: border-box;
+ border-radius: .5rem;
+}
+
+main > :first-child {
+ margin-top: 0;
+}
+
+footer {
+ color: var(--foreground-secondary);
+ text-align: left;
+ grid-area: footer;
+ padding: .5rem 1rem;
+ padding-top: 0;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+}
+
+footer a {
+ color: var(--foreground-secondary);
+}
+
+footer * {
+ margin: 0 auto;
+}
+
+li:not(:last-child) {
+ margin-bottom: .5rem;
}
@media (orientation: portrait) {
- #root {
+ @media (max-width: 749px) {
+ #title {
+ display: none;
+ }
+
+ footer :not(.important) {
+ display: none;
+ }
+ }
+
+ #root.sidebar-visible {
grid-template-areas:
"header"
"sidebar"
"main"
"footer";
- grid-template-rows: auto auto 1fr auto;
+ grid-template-rows: auto auto 2fr auto;
}
}
@@ -55,6 +98,10 @@ main > * {
grid-template-areas:
"header header"
"sidebar main"
- "sidebar footer";
+ "footer footer";
+ }
+
+ main {
+ margin-right: 1rem;
}
}
diff --git a/src/App.tsx b/src/App.tsx
index da0a3b78..3cd1d649 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -1,85 +1,30 @@
import * as React from "react";
-import {ReactNode} from "react";
import './App.css';
-import {localize} from "./main";
-import config from "./config";
-import Header from "./components/Header";
-import NavigationTutorial from "./components/NavigationTutorial";
-import Notification from "./components/Notification";
-import Uploader from "./components/Uploader";
+import {strings} from "./main";
import View from "./components/View";
-import {graphModel, loadData} from "./backend/ModelGraph";
import {BrowserRouter, Route, Routes} from "react-router-dom";
-
-interface State {
- notifications: ReactNode[]
- dataAvailable: boolean
-}
-
-class App extends React.Component {
- constructor(props) {
- super(props);
- new URL(window.location.href);
- let data = sessionStorage.getItem("familyData");
- if (data) {
- loadData(JSON.parse(data));
- }
-
- this.state = {
- notifications: [],
- dataAvailable: graphModel !== undefined
- };
- }
-
- render() {
- return
-
- {this.state.notifications}
-
-
-
-
-
- }/>
- }/>
-
-
- }
-
- onFileSelected(fileContent) {
- sessionStorage.setItem("familyData", fileContent);
- loadData(JSON.parse(fileContent));
- if (window.location.href.endsWith("/")) {
- window.location.href += "view";
- } else {
- window.location.href += "/view";
- }
- }
-
- componentDidMount() {
- localize(config.browserLang);
- }
-
- componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
- let notifications = this.state.notifications;
-
- if (error.name.startsWith("Warning")) {
- console.warn(error.message);
- notifications.push(
-
- );
- } else {
- console.error(error.message);
- notifications.push(
-
- );
- }
-
- this.setState({
- notifications: notifications
- })
- }
+import {Home, Imprint} from "./components/Home";
+
+function App() {
+ return
+
+ }/>
+ }/>
+ }/>
+
+
+
}
export default App;
diff --git a/src/backend/ModelGraph.ts b/src/backend/ModelGraph.ts
index c724e196..55f6f37a 100644
--- a/src/backend/ModelGraph.ts
+++ b/src/backend/ModelGraph.ts
@@ -1,5 +1,4 @@
import GedcomX, {setReferenceAge} from "./gedcomx-extensions";
-import {translationToString} from "../main";
import viewGraph, {ViewMode} from "./ViewGraph";
import config from "../config";
import {PersonFactTypes} from "./gedcomx-enums";
@@ -10,13 +9,7 @@ class ModelGraph extends GedcomX.Root {
constructor(data) {
super(data)
if (!data || data.persons.length < 0 || data.relationships.length < 0) {
- throw new Error(
- translationToString({
- en: "The calculated graph is empty!" +
- "Please check if your files are empty. If not, please contact the administrator!",
- de: "Der berechnete Graph ist leer!" +
- " Prüfe bitte, ob die Dateien leer sind. Sollte dies nicht der Fall sein, kontaktiere bitte den Administrator!"
- }));
+ throw new Error("The calculated graph is empty! Please check if your files are empty. If not, please file a bug report!");
}
console.log("Found", data.persons.length, "people");
@@ -30,6 +23,10 @@ class ModelGraph extends GedcomX.Root {
return super.getPersonById(id);
}
+ getSourceDescriptionById(id: string): GedcomX.SourceDescription {
+ return this.getSourceDescriptions().find(d => d.getId() === id);
+ }
+
getPersonByName = (name: string): GedcomX.Person => {
return this.persons.find(person => person.getFullName().toLowerCase().includes(name));
}
diff --git a/src/backend/gedcomx-enums.ts b/src/backend/gedcomx-enums.ts
index 1b265811..e374ff3f 100644
--- a/src/backend/gedcomx-enums.ts
+++ b/src/backend/gedcomx-enums.ts
@@ -88,4 +88,11 @@ export enum OccupationCategories {
King = "King"
}
+export enum KnownResourceTypes {
+ Collection = "http://gedcomx.org/Collection",
+ PhysicalArtifact ="http://gedcomx.org/PhysicalArtifact",
+ DigitalArtifact = "http://gedcomx.org/DigitalArtifact",
+ Record = "http://gedcomx.org/Record"
+}
+
export const baseUri = "http://gedcomx.org/";
diff --git a/src/backend/gedcomx-extensions.ts b/src/backend/gedcomx-extensions.ts
index 5bc2c056..f0867658 100644
--- a/src/backend/gedcomx-extensions.ts
+++ b/src/backend/gedcomx-extensions.ts
@@ -1,5 +1,5 @@
import * as GedcomX from "gedcomx-js";
-import {translationToString} from "../main";
+import {strings} from "../main";
import config from "../config";
import {
baseUri,
@@ -212,10 +212,9 @@ function extend(GedcomXExtend) {
time = dateObject.toLocaleTimeString(config.browserLang, options);
}
- return translationToString({
- en: `${this.getFormal().length >= 11 ? "on" : "in"} ${date}${time ? " at " + time : ""}`,
- de: `${this.getFormal().length >= 11 ? "am" : "in"} ${date}${time ? " um " + time : ""}`
- })
+ const length = this.getFormal().length;
+
+ return `${strings.formatString(length >= 10 ? strings.gedcomX.day : (length >= 7 ? strings.gedcomX.month : strings.gedcomX.year), date)}${time ? " " + strings.formatString(strings.gedcomX.time, time) : ""}`;
}
@@ -227,69 +226,39 @@ function extend(GedcomXExtend) {
switch (this.getType()) {
case PersonFactTypes.Birth:
- string = translationToString({
- en: "born",
- de: "geboren"
- });
+ string = strings.gedcomX.born;
break;
case PersonFactTypes.Generation:
- string = translationToString({
- en: "Generation",
- de: "Generation"
- });
+ string = strings.gedcomX.generation;
break;
case PersonFactTypes.MaritalStatus:
- string = translationToString({
- en: "",
- de: ""
- });
+ string = "";
switch (value) {
case "single":
- value = translationToString({
- en: "single",
- de: "ledig"
- });
+ value = strings.gedcomX.single;
break;
case "married":
- value = translationToString({
- en: "married",
- de: "verheiratet"
- });
+ value = strings.gedcomX.married;
break;
}
break;
case PersonFactTypes.Religion:
- string = translationToString({
- en: "Religion:",
- de: "Religion:"
- });
+ string = strings.gedcomX.religion;
break;
case PersonFactTypes.Occupation:
- string = translationToString({
- en: "works as",
- de: "arbeitet als"
- })
+ string = strings.gedcomX.worksAs;
break;
case PersonFactTypes.Death:
- string = translationToString({
- en: "died",
- de: "verstorben"
- });
+ string = strings.gedcomX.died;
break;
default:
string = this.getType();
break;
}
- string += translationToString({
- en: (value || value === "0" ? ` ${value}` : "") +
- (this.getDate() !== undefined ? ` ${this.getDate().toString()}` : "") +
- (this.getPlace() && this.getPlace().toString() ? ` in ${this.getPlace().toString()}` : ""),
-
- de: (value || value === "0" ? " " + value : "") +
- (this.getDate() !== undefined ? ` ${this.getDate().toString()}` : "") +
- (this.getPlace() && this.getPlace().toString() ? " in " + this.getPlace().toString() : "")
- });
+ string += (value || value === "0" ? ` ${value}` : "") +
+ (this.getDate() !== undefined ? ` ${this.getDate().toString()}` : "") +
+ (this.getPlace() && this.getPlace().toString() ? " " + strings.formatString(strings.gedcomX.place, this.getPlace().toString()) : "");
if (this.getQualifiers() && this.getQualifiers().length > 0) {
string += " " + this.getQualifiers().map(q => q.toString()).join(" ");
@@ -325,10 +294,7 @@ function extend(GedcomXExtend) {
let string;
switch (this.getName()) {
case PersonFactQualifiers.Age:
- string = translationToString({
- en: `with ${this.getValue()} years old`,
- de: `mit ${this.getValue()} Jahren`
- });
+ string = strings.formatString(strings.gedcomX.ageQualifier, this.getValue());
break;
case PersonFactQualifiers.Cause:
string = `(${this.getValue()})`;
diff --git a/src/backend/gedcomx-js.d.ts b/src/backend/gedcomx-js.d.ts
index 47dc8655..f841b716 100644
--- a/src/backend/gedcomx-js.d.ts
+++ b/src/backend/gedcomx-js.d.ts
@@ -19,16 +19,18 @@ declare module "gedcomx-js" {
}
export class Root extends ExtensibleData {
+ id: string
lang: string
+ attribution: Attribution
persons: Person[]
relationships: Relationship[]
- description: string
- sourceDescriptions
+ sourceDescriptions: SourceDescription[]
agents
- events
- documents
+ events: Event[]
+ documents: Document[]
places
- attribution
+ groups
+ description: string
getPersons(): Person[]
@@ -57,6 +59,8 @@ declare module "gedcomx-js" {
setRelationships(relationships: Relationship[] | object[]): Root
addRelationship(relationship: Relationship | object): Root
+
+ getSourceDescriptions(): SourceDescription[]
}
export function GedcomX(json: any): Root;
@@ -109,17 +113,17 @@ declare module "gedcomx-js" {
setLang(lang);
- getNotes();
+ getNotes(): Note[];
- setNotes(notes: []);
+ setNotes(notes: Note[]);
- addNote(note);
+ addNote(note: Note);
- getSources();
+ getSources(): SourceReference[];
- setSources(sources: []);
+ setSources(sources: SourceReference[]);
- addSource(source);
+ addSource(source: SourceReference);
}
export class EvidenceReference extends ResourceReference {
@@ -140,6 +144,100 @@ declare module "gedcomx-js" {
getAttribution(): Attribution
setAttribution(attribution: object | Attribution): SourceReference
+
+ getQualifiers()
+
+ setQualifiers(qualifiers: Qualifier[])
+ }
+
+ export class SourceDescription {
+ getId(): string
+
+ setId(id: string)
+
+ getResourceType()
+
+ setResourceType()
+
+ getCitations(): Citation[]
+
+ setCitations(citations: any[])
+
+ getMediaType(): string
+
+ setMediaType(mediaType: string)
+
+ getAbout()
+
+ setAbout(about: string)
+
+ getMediator()
+
+ setMediator()
+
+ getPublisher()
+
+ setPublisher()
+
+ getAuthors()
+
+ setAuthors()
+
+ getSources(): SourceReference[]
+
+ setSources(source : SourceReference[])
+
+ getAnalysis()
+
+ setAnalysis()
+
+ getComponentOf()
+
+ setComponentOf()
+
+ getTitles()
+
+ setTitles()
+
+ getNotes(): Note[]
+
+ setNotes(notes: Note[])
+
+ getAttribution()
+
+ setAttribution()
+
+ getRights()
+
+ setRights()
+
+ getCoverage()
+
+ setCoverage()
+
+ getDescriptions()
+
+ setDescriptions()
+
+ getIdentifiers(): Identifier[]
+
+ setIdentifiers()
+
+ getCreated()
+
+ setCreated()
+
+ getModified()
+
+ setModified()
+
+ getPublished()
+
+ setPublished()
+
+ getRepository()
+
+ setRepository()
}
export class Identifiers extends Base {
@@ -455,4 +553,42 @@ declare module "gedcomx-js" {
setFamiliesAsChild(families: FamilyView[]): DisplayProperties
}
+
+ export class Identifier {
+ getValue(): string
+
+ setValue(value: string)
+
+ getType(): string
+
+ setType(type: string)
+ }
+
+ export class Citation {
+ getValue(): string
+
+ setValue(value: string)
+
+ getLang(): string
+
+ setLang(lang: string)
+ }
+
+ export class Note {
+ getLang(): string
+
+ setLang(lang: string)
+
+ getSubject(): string
+
+ setSubject(subject: string)
+
+ getText(): string
+
+ setText(text: string)
+
+ getAttribution(): Attribution
+
+ setAttribution(attribution: Attribution)
+ }
}
diff --git a/src/components/Article.css b/src/components/Article.css
index db3f7da5..0d6fa464 100644
--- a/src/components/Article.css
+++ b/src/components/Article.css
@@ -1,5 +1,4 @@
article, .notification {
- border-radius: .5rem;
padding: .75rem 1rem;
max-width: 50rem;
width: 100%;
@@ -12,6 +11,18 @@ article h1 {
padding-bottom: .75rem;
}
+aside ul, aside ol {
+ padding-left: 1rem;
+}
+
+article :first-child {
+ margin-top: 0;
+}
+
+article :last-child {
+ margin-bottom: 0;
+}
+
details {
background: var(--background-lower);
border-radius: .5rem;
diff --git a/src/components/Article.tsx b/src/components/Article.tsx
deleted file mode 100644
index 6d09c084..00000000
--- a/src/components/Article.tsx
+++ /dev/null
@@ -1,12 +0,0 @@
-import './Article.css';
-
-function Article(props) {
- return (
-
- {props.emoji} {props.title}
- {props.children}
-
- );
-}
-
-export default Article;
diff --git a/src/components/FamilyPath.css b/src/components/FamilyPath.css
deleted file mode 100644
index 28fedc0c..00000000
--- a/src/components/FamilyPath.css
+++ /dev/null
@@ -1,28 +0,0 @@
-footer {
- background: inherit;
- border-top: solid var(--foreground-secondary) .1rem;
- text-align: left;
- grid-area: footer;
- padding: .5rem 1rem;
- width: 100%;
- display: flex;
- justify-content: space-between;
-}
-
-#family-path {
- margin: 0;
- padding-left: 0;
-}
-
-#family-path li {
- display: inline;
-}
-
-#family-path li:not(:last-child):after {
- content: " > ";
- font-weight: normal !important;
-}
-
-#family-path li.focusPerson {
- font-weight: bold;
-}
diff --git a/src/components/FamilyPath.tsx b/src/components/FamilyPath.tsx
deleted file mode 100644
index ba3f7f74..00000000
--- a/src/components/FamilyPath.tsx
+++ /dev/null
@@ -1,37 +0,0 @@
-import "./FamilyPath.css";
-import {graphModel} from "../backend/ModelGraph";
-import viewGraph from "../backend/ViewGraph";
-import {useState} from "react";
-import {GraphObject} from "../backend/graph";
-import {translationToString} from "../main";
-import config from "../config";
-
-function FamilyPath(props) {
- const updateValue = (e: CustomEvent) => setN(e.detail.nodes.filter((n: GraphObject) => n.type === "person").length)
-
- let [n, setN] = useState(viewGraph.nodes.filter(n => n.type === "person").length)
- viewGraph.addEventListener("add", updateValue)
- viewGraph.addEventListener("remove", updateValue)
-
- return (
-
- );
-}
-
-export default FamilyPath;
diff --git a/src/components/Form.css b/src/components/Form.css
index fd756889..4bca4d9e 100644
--- a/src/components/Form.css
+++ b/src/components/Form.css
@@ -27,19 +27,30 @@ input[type=submit], button, .button {
font-weight: bold;
border-radius: 2.33rem;
padding: .66rem 1rem;
+ margin-left: 1rem;
border: none;
text-decoration: none;
+ cursor: pointer;
+}
+
+.button {
+ display: inline-block;
+}
+
+.button:visited {
+ color: inherit;
}
.button.inline, button.inline {
- padding: .33rem 1rem;
+ padding: .33em 1em;
}
-.button.inactive, button.inactive {
+.button.inactive, button.inactive, input[type=submit].inactive {
background: var(--background);
color: var(--primary);
font-weight: normal;
border: solid var(--primary) 2pt;
+ cursor: not-allowed;
}
@media (prefers-color-scheme: dark) {
@@ -76,16 +87,14 @@ input[type=submit]:active, button:active, .button:not(select):active {
}
input[type=search] {
+ border-radius: 1.5em;
border: none;
- border-bottom: solid white;
- border-radius: 0;
- background: none;
- color: white;
- padding: 0 .25rem;
+ background: rgba(255, 255, 255, 0.5);
+ color: black;
+ padding: 0 .75em;
min-width: 10rem;
width: 100%;
flex-shrink: 2;
- margin-left: 1rem;
}
input[type=search].error, input[type=search]:invalid {
@@ -110,6 +119,26 @@ input[type=search]:focus {
text-shadow: 0 0 1rem var(--primary);
}
-button:hover, input[type=submit]:hover, input[type=file] {
- cursor: pointer;
+.card {
+ text-align: center;
+ background: var(--background-higher);
+ border-radius: 1rem;
+ padding: .66rem 1rem;
+ margin-left: auto;
+ margin-right: auto;
+}
+
+.file-selected {
+ background: var(--positive-green);
+}
+
+.file-upload *:not(:last-child) {
+ margin-bottom: .5rem;
+}
+
+.icon-only {
+ background: none;
+ padding: 0;
+ border: none;
+ margin: 0;
}
diff --git a/src/components/Form.tsx b/src/components/Form.tsx
index d727ca5d..559a7398 100644
--- a/src/components/Form.tsx
+++ b/src/components/Form.tsx
@@ -1,55 +1,20 @@
import './Form.css';
-import './FormInput.css';
-import {Component} from "react";
import * as React from "react";
-import {translationToString} from "../main";
+import {strings} from "../main";
+import {useState} from "react";
-class Form extends Component {
- public input: React.RefObject;
+function Form(props) {
+ const [focused, setFocused] = useState(false);
+ const [file, setFile] = useState("");
- constructor(props) {
- super(props);
- this.state = {
- focused: false,
- file: ""
- }
- this.input = React.createRef();
- }
-
- render() {
- return (
-
- );
- }
+ let input = React.createRef();
- checkDropAllowed(e) {
+ function checkDropAllowed(e) {
e.preventDefault();
if (e.dataTransfer.items[0].type === "application/json") {
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.dropEffect = "copy";
- this.setState({
- focused: true
- });
+ setFocused(true);
return;
}
@@ -58,48 +23,69 @@ class Form extends Component {
e.dataTransfer.dropEffect = "none";
}
- onDrop(e) {
+ function onDrop(e) {
e.preventDefault();
- this.setState({
- focused: false,
- file: e.dataTransfer.files[0].name
- });
+ setFocused(false);
+ setFile(e.dataTransfer.files[0].name);
document.querySelector("#gedcom-file").files = e.dataTransfer.files;
}
- removeFocus() {
- this.setState({
- focused: false
- })
+ function removeFocus() {
+ setFocused(false);
}
- onSubmit(event) {
- event.preventDefault();
+ return (
+
+ );
+}
- if (!gedcomFile) {
- throw new Error(translationToString({
- en: "No gedcom file selected",
- de: "Keine Datei ausgewählt"
- }))
- }
+export async function parseFile(gedcomFile) {
+ if (!gedcomFile) {
+ throw new Error(strings.form.noFileError)
+ }
- let readerGedcom = new FileReader();
- readerGedcom.onload = (file) => {
- if (typeof file.target.result === "string") {
- sessionStorage.setItem("familyData", file.target.result);
- this.props.onSubmit(file.target.result);
- } else {
- throw new Error(translationToString({
- en: "The graph could not be loaded.",
- de: "Der Graph konnte nicht geladen werden."
- }))
- }
+ let readerGedcom = new FileReader();
+ readerGedcom.onload = (file) => {
+ if (typeof file.target.result === "string") {
+ localStorage.setItem("familyData", file.target.result);
+ return file.target.result;
+ } else {
+ throw new Error(strings.form.graphLoadingError)
}
- readerGedcom.readAsText(gedcomFile);
}
+ readerGedcom.readAsText(gedcomFile);
+}
+
+export function saveDataAndRedirect(fileContent) {
+ localStorage.setItem("familyData", fileContent);
+ let url = new URL(window.location.href);
+ url.pathname = "/family-tree/view";
+ window.location.href = url.href;
}
export default Form;
diff --git a/src/components/FormInput.css b/src/components/FormInput.css
deleted file mode 100644
index 815c19aa..00000000
--- a/src/components/FormInput.css
+++ /dev/null
@@ -1,16 +0,0 @@
-.card {
- text-align: center;
- background: var(--background-higher);
- border-radius: 1rem;
- padding: .66rem 1rem;
- margin-left: auto;
- margin-right: auto;
-}
-
-.file-selected {
- background: var(--positive-green);
-}
-
-.file-upload *:not(:last-child) {
- margin-bottom: .5rem;
-}
diff --git a/src/components/Gallery.tsx b/src/components/Gallery.tsx
new file mode 100644
index 00000000..334307bb
--- /dev/null
+++ b/src/components/Gallery.tsx
@@ -0,0 +1,19 @@
+import {useState} from "react";
+
+interface Props {
+ children
+}
+
+export function Gallery(props: Props) {
+ const [index, scroll] = useState(0);
+
+ return
+ {props.children[index]}
+ {props.children.length > 1 &&
+ {
+ scroll(i => Math.max(0, i - 2 /* why 2?? */))}>⬅ }
+ {
+ scroll(i => Math.min(props.children.length - 1, i + 2))}>➡ }
+ }
+ ;
+}
diff --git a/src/components/Header.css b/src/components/Header.css
index 991d37aa..79885c06 100644
--- a/src/components/Header.css
+++ b/src/components/Header.css
@@ -1,15 +1,13 @@
header {
- padding: .75rem 1rem;
+ padding: .25rem 1rem;
z-index: 1;
-}
-
-header {
box-shadow: 0 .25rem .25rem rgba(0, 0, 0, 0.33);
display: flex;
align-items: center;
background: var(--primary);
color: white;
grid-area: header;
+ gap: 1rem;
}
header ::selection {
@@ -21,6 +19,11 @@ header * {
font-size: 2rem;
}
-header > *:not(:last-child) {
- margin-right: 1rem;
+header div {
+ flex-grow: 2;
+}
+
+header input {
+ margin: 0;
+ font-size: 1.5rem;
}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx
index 0e368086..2badb771 100644
--- a/src/components/Header.tsx
+++ b/src/components/Header.tsx
@@ -1,29 +1,34 @@
+import * as React from "react";
import './Header.css';
import {Link} from 'react-router-dom';
-import {translationToString} from "../main";
+import {strings} from "../main";
+import {parseFile} from "./Form";
+import {saveDataAndRedirect} from "./Form";
function Header(props) {
+ let fileInput = React.createRef();
+
return (
-
-
+
+
- {translationToString({
- en: "Family tree",
- de: "Stammbaum"
- })}
+ {strings.header.title}
{props.children}
+
+
+
);
}
-function removeData() {
- window.sessionStorage.removeItem("familyData")
-}
-
export default Header;
diff --git a/src/components/Home.tsx b/src/components/Home.tsx
new file mode 100644
index 00000000..76bb8c2a
--- /dev/null
+++ b/src/components/Home.tsx
@@ -0,0 +1,80 @@
+import * as React from "react";
+import Header from "./Header";
+import {strings} from "../main";
+import Form from "./Form";
+import "./Article.css";
+
+export function Home() {
+ return <>
+
+
+
+
+
+ >;
+}
+
+function Uploader() {
+ let root = document.getElementById("root");
+ root.classList.remove("sidebar-visible");
+
+ return (
+
+
+ {strings.home.uploadArticle.content}
+
+
+ );
+}
+
+function Article(props) {
+ return (
+
+ {props.emoji} {props.title}
+ {props.children}
+
+ );
+}
+
+
+function NavigationTutorial() {
+ return
+ {strings.formatString(strings.home.navigationArticle.content,
+ {strings.ctrl} )}
+
+}
+
+export function Imprint() {
+ return <>
+
+
+
+
+ {strings.formatString(strings.imprint.privacyArticle.content,
+
+ {strings.linkContent}
+ )}
+
+
+
+
+
+ Hoffmann, Lorenz
+ Robert-Sterl Str 5c
+ 01219 Dresden
+ hoffmann_lorenz@protonmail.com
+
+
+
+
+ >
+}
diff --git a/src/components/InfoPanel.css b/src/components/InfoPanel.css
index 306b06a5..bd7910e5 100644
--- a/src/components/InfoPanel.css
+++ b/src/components/InfoPanel.css
@@ -1,59 +1,116 @@
aside {
+ overflow-y: scroll;
+ overflow-x: hidden;
+ display: flex;
+ gap: 1rem;
+ flex-flow: row;
+ max-width: 100vw;
+ flex-wrap: wrap;
grid-area: sidebar;
- background: rgba(70, 155, 24, 0.26);
- padding: 1rem;
- box-shadow: 0 .25rem .25rem rgba(0, 0, 0, 0.33);
+ max-height: 30vh;
+ padding: 0 1rem;
}
-#info-panel {
- overflow: scroll;
+aside > * {
+ flex: 1 1 0;
}
-#info-panel h1, #info-panel h2 {
- text-align: center;
+aside .title {
+ flex-basis: 100%;
}
-#info-panel h2 {
- font-weight: normal;
- margin: .25em 0;
+aside .title :first-child {
+ margin-top: 0;
}
-#info-panel form {
- margin: 0 auto;
- width: fit-content;
+aside .title :last-child {
+ margin-bottom: 0;
}
-#info-panel input[type=search] {
- color: var(--foreground);
- border-bottom: solid transparent;
- font-size: 2em;
- font-weight: bold;
+aside h1, aside article h1 {
+ font-size: 1.5rem;
+}
+
+aside .title > h1, aside .title > h2 {
text-align: center;
- transition: all .3s;
}
-#info-panel input[type=search]:hover, #info-panel input[type=search]:focus {
- border-bottom: solid var(--foreground);
+aside .title h2 {
+ font-weight: normal;
}
-#info-panel #factView {
- background: var(--background);
- padding: 1rem 1rem 1rem 2rem;
+@media (orientation: landscape) {
+ aside {
+ flex-flow: column;
+ padding-right: 0;
+ max-height: unset;
+ }
+
+ aside .title {
+ flex-basis: 0;
+ }
+}
+
+aside > article {
border-radius: 1rem;
+ padding: 1rem;
+ margin: 0;
+ box-sizing: border-box;
+ background: var(--background);
box-shadow: inset 0 .25rem .25rem rgba(0, 0, 0, .33);
+ flex: 1 1 200px;
}
-#info-panel #factView li:not(:last-child) {
- margin-bottom: .5rem;
+article.gallery {
+ padding: 0;
+ z-index: 0;
+ position: relative;
+ box-shadow: none;
}
-.id::before {
- content: "#";
+article.gallery div {
+ box-shadow: inset 0 .25rem .25rem rgba(0, 0, 0, .33);
+ border-radius: 1rem;
+ z-index: 0;
+}
+
+.gallery img {
+ width: 100%;
+ border-top-left-radius: 1rem;
+ border-top-right-radius: 1rem;
+ position: relative;
+ /* z-index is needed for shadow effect */
+ z-index: -1;
+}
+
+.credits {
+ padding-top: .25rem;
+ padding-bottom: .25rem;
+ text-align: center;
+ display: block;
+ white-space: nowrap;
+ overflow: hidden;
+ font-size: .75rem;
+ z-index: 1;
+}
+
+.gallery .buttons {
+ position: absolute;
+ bottom: .5rem;
+ width: 100%;
+ display: flex;
+ justify-content: space-between;
+ justify-items: center;
+ padding: 0 1em;
+ box-sizing: border-box;
}
-.id {
- color: var(--foreground-secondary);
- text-align: right;
+.gallery .buttons button {
margin: 0;
- float: right;
+ font-size: .75rem;
+}
+
+#confidence {
+ flex-grow: 2;
+ text-align: center;
}
diff --git a/src/components/InfoPanel.tsx b/src/components/InfoPanel.tsx
index d458507d..6e5ae252 100644
--- a/src/components/InfoPanel.tsx
+++ b/src/components/InfoPanel.tsx
@@ -1,24 +1,69 @@
import './InfoPanel.css';
-import {Component} from "react";
-import SearchField from "./SearchField";
import {Person} from "gedcomx-js";
-import {PersonFactTypes} from "../backend/gedcomx-enums";
+import {baseUri, PersonFactTypes} from "../backend/gedcomx-enums";
+import {strings} from "../main";
+import {graphModel} from "../backend/ModelGraph";
+import {Gallery} from "./Gallery";
interface Props {
onRefocus: (newFocus: Person) => void,
person: Person
}
-class InfoPanel extends Component {
- render() {
- let person = this.props.person;
- return (
-
+ );
+}
+
+function getImages(person: Person) {
+ let mediaRefs = person.getMedia().map(media => media.getDescription());
+ return mediaRefs.map(ref => {
+ let sourceDescription = graphModel.getSourceDescriptionById(ref.replace('#', ''));
+ if (!sourceDescription) throw Error(`Could not find a source description with id ${ref}`);
+ return sourceDescription;
+ }).filter(sourceDescription => {
+ let mediaType = sourceDescription.getMediaType();
+ if (!mediaType) return false;
+ return mediaType.split('/')[0] === 'image'
+ });
}
diff --git a/src/components/NavigationTutorial.tsx b/src/components/NavigationTutorial.tsx
deleted file mode 100644
index 301e7ef9..00000000
--- a/src/components/NavigationTutorial.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import Article from "./Article";
-import {translationToString} from "../main";
-
-function NavigationTutorial() {
- return (
- <>
-
- {translationToString({
- en:
- You can move the family tree with your mouse.While pressing Ctrl , you can zoom in and out
- with your mouse wheel.
- Select a person with your left mouse button to show their information.
- Many people have a circle with "+" or "-" inside them, clicking on those displays their relatives.
-
,
- de:
- Man kann den Stammbaum durch Ziehen mit der Maus verschieben. Hält man Strg gedrückt, kann
- man mit dem Mausrad rein- bzw. rauszoomen.
- Klickt man auf eine Person werden weitere Informationen zu dieser angezeigt.
- An vielen Personen hängen Kreise, in denen "+" oder "-" steht. Klickt man auf diese, werden weitere
- Verwandte ein- oder ausgeblendet.
-
- })}
-
- >
- );
-}
-
-export default NavigationTutorial;
diff --git a/src/components/Nodes.tsx b/src/components/Nodes.tsx
index 48580988..d08fee2c 100644
--- a/src/components/Nodes.tsx
+++ b/src/components/Nodes.tsx
@@ -1,5 +1,5 @@
import config from "../config";
-import {translationToString} from "../main";
+import {strings} from "../main";
import viewGraph from "../backend/ViewGraph";
import {GraphPerson} from "../backend/graph";
@@ -17,20 +17,14 @@ export function Family(props) {
{`💍 ${props.data.marriage}`}
}
{props.locked ? "🔒" : "➖"}
- {props.locked ? translationToString({
- en: "This family cannot be hidden.",
- de: "Diese Familie kann nicht ausgeblendet werden."
- }) : translationToString({
- en: "Click to hide this family.",
- de: "Klicke, um diese Familie auszublenden."
- })}
+ {props.locked ? strings.nodes.lockedFamilyHint : strings.nodes.hideFamilyHint}
);
}
export function Etc(props) {
return (
- viewGraph.showFamily(props.data)}>
+ props.graph.showFamily(props.data)}>
➕
@@ -45,10 +39,7 @@ export function Person(props) {
x={graphPerson.x - graphPerson.width / 2} y={graphPerson.y - graphPerson.height / 2}
width={graphPerson.width} height={graphPerson.height}
onClick={() => props.onClick(graphPerson.data)}>
-
+
diff --git a/src/components/Notification.css b/src/components/Notification.css
deleted file mode 100644
index 85c12547..00000000
--- a/src/components/Notification.css
+++ /dev/null
@@ -1,11 +0,0 @@
-.warning {
- color: var(--warning-foreground);
- background: var(--warning-background);
- border-color: var(--warning-foreground);
-}
-
-.error {
- color: var(--error-foreground);
- background: var(--error-background);
- border-color: var(--error-foreground);
-}
diff --git a/src/components/Notification.tsx b/src/components/Notification.tsx
deleted file mode 100644
index a84ddac2..00000000
--- a/src/components/Notification.tsx
+++ /dev/null
@@ -1,29 +0,0 @@
-import {translationToString} from "../main";
-import "./Notification.css";
-
-function Notification(props) {
- if (props.type === "warning") {
- return (
-
- ⚠ ️ {translationToString({
- en: "Warning",
- de: "Achtung"
- })} |
- {props.description}
-
- );
- }
- if (props.type === "error") {
- return (
-
- 💥 {translationToString({
- en: "Error",
- de: "Fehler"
- })} |
- {props.description}
-
- );
- }
-}
-
-export default Notification;
diff --git a/src/components/SearchField.tsx b/src/components/SearchField.tsx
index 54233dad..40c639b8 100644
--- a/src/components/SearchField.tsx
+++ b/src/components/SearchField.tsx
@@ -1,51 +1,16 @@
-import {Component} from "react";
-import {translationToString} from "../main";
+import {useEffect, useState} from "react";
+import {strings} from "../main";
import {graphModel} from "../backend/ModelGraph";
import {Person} from "gedcomx-js";
interface Props {
- person: Person,
onRefocus: (newFocus: Person) => void
}
-interface State {
- hasError: boolean
-}
+function SearchField(props: Props) {
+ const [hasError, setHasError] = useState(false);
-class SearchField extends Component
{
- constructor(props) {
- super(props);
- this.state = {
- hasError: false
- }
- }
-
- render() {
- return (
-
- );
- }
-
- componentDidMount() {
+ useEffect(() => {
// add keyboard shortcuts
document.addEventListener("keydown", event => {
switch (event.key) {
@@ -59,9 +24,9 @@ class SearchField extends Component {
document.querySelector(":focus").blur();
}
});
- }
+ }, []);
- refocus(event) {
+ function refocus(event) {
event.preventDefault();
let name = document.querySelector("#input-name").value;
if (name) {
@@ -69,26 +34,39 @@ class SearchField extends Component {
let person = graphModel.getPersonByName(name.toLowerCase());
// if no person was found, throw error
- this.setState({hasError: !person});
+ setHasError(!person);
if (!person) {
- window.alert(translationToString({
- en: "No person with that name found!",
- de: "Es konnte keine Person mit diesem Namen gefunden werden!"
- }));
+ window.alert(strings.searchField.noPersonFound);
return;
}
console.log(`Assuming the person is ${person.getFullName()} with id ${person.getId()}`);
- this.props.onRefocus(person);
+ props.onRefocus(person);
}
}
- resetError() {
+ function resetError() {
let name = document.querySelector("#input-name").value;
if (!name) {
- this.setState({hasError: false});
+ setHasError(false);
}
}
+
+ return (
+
+ );
}
export default SearchField;
diff --git a/src/components/Sidepanel.css b/src/components/Sidepanel.css
deleted file mode 100644
index d4458407..00000000
--- a/src/components/Sidepanel.css
+++ /dev/null
@@ -1,10 +0,0 @@
-aside {
- grid-area: sidebar;
- background: rgba(70, 155, 24, 0.26);
- padding: 1rem;
- box-shadow: 0 .25rem .25rem rgba(0, 0, 0, 0.33);
-}
-
-aside h1, aside h2 {
- text-align: center;
-}
diff --git a/src/components/TreeView.tsx b/src/components/TreeView.tsx
index 611b6075..15c15b5b 100644
--- a/src/components/TreeView.tsx
+++ b/src/components/TreeView.tsx
@@ -1,10 +1,10 @@
import {Etc, Family, Person} from "./Nodes";
-import {Component} from "react";
+import {useEffect, useState} from "react";
import config from "../config";
import * as d3 from "d3";
import * as cola from "webcola";
import * as GedcomX from "gedcomx-js";
-import viewGraph, {ColorMode, ViewGraph} from "../backend/ViewGraph";
+import {ColorMode, ViewGraph} from "../backend/ViewGraph";
import {GraphFamily, GraphPerson} from "../backend/graph";
let d3cola = cola.d3adaptor(d3);
@@ -12,223 +12,211 @@ let d3cola = cola.d3adaptor(d3);
interface Props {
focus: GedcomX.Person
focusHidden: boolean
- onRefocus: (newFocus: GraphPerson) => void
- colorMode: ColorMode | string
-}
-
-interface State {
+ onRefocus: (newFocus: GedcomX.Person) => void
+ colorMode: ColorMode,
graph: ViewGraph
}
-class TreeView extends Component {
- mounted = false
-
- constructor(props) {
- super(props);
+function TreeView(props: Props) {
+ const [, updateGraph] = useState(props.graph.nodes.length);
- viewGraph.addEventListener("add", this.onGraphChanged.bind(this));
- viewGraph.addEventListener("remove", this.onGraphChanged.bind(this));
-
- this.state = {
- graph: viewGraph
- }
+ function onGraphChanged() {
+ updateGraph(props.graph.nodes.length);
}
- render() {
- console.assert(viewGraph.nodes.length > 0,
- "View graph has no nodes!");
- console.assert(viewGraph.links.length > 0,
- "View graph has no links!");
- d3cola
- .flowLayout("x", config.gridSize * 5)
- .nodes(this.state.graph.nodes)
- .links(this.state.graph.links)
- .start(10, 0, 10);
-
- return (
-
-
-
-
- {this.state.graph.links.map((l, i) =>
- )}
-
-
- {this.state.graph.nodes.filter(n => n.type === "family").map((r, i) =>
- )}
- {this.state.graph.nodes.filter(n => n.type === "etc").map((r, i) =>
- )}
- {this.state.graph.nodes.filter(n => n.type === "person").map((p, i) =>
- )}
-
+ props.graph.addEventListener("add", onGraphChanged);
+ props.graph.addEventListener("remove", onGraphChanged);
+
+ useEffect(() => {
+ setupCola();
+ window.matchMedia("(prefers-color-scheme: dark)")
+ .addEventListener("change", () => animateTree(props.graph, props.colorMode));
+ }, [props.graph, props.colorMode])
+
+ const focusId = props.focus.getId();
+ const nodeLength = props.graph.nodes.length;
+
+ useEffect(() => {
+ animateTree(props.graph, props.colorMode);
+ }, [focusId, nodeLength, props.graph, props.colorMode]);
+
+ console.assert(props.graph.nodes.length > 0,
+ "View graph has no nodes!");
+ console.assert(props.graph.links.length > 0,
+ "View graph has no links!");
+ d3cola
+ .flowLayout("x", config.gridSize * 5)
+ .nodes(props.graph.nodes)
+ .links(props.graph.links)
+ .start(10, 0, 10);
+
+ return (
+
+
+
+
+ {props.graph.links.map((l, i) =>
+ )}
-
- );
- }
-
- componentDidMount() {
- let svg = d3.select("#family-tree");
- this.mounted = true;
-
- const viewportSize = [svg.node().getBBox().width, svg.node().getBBox().height];
- d3cola.size(viewportSize);
-
- // catch the transformation events
- /*
- I have changed the default zoom behavior to the following one:
- - Nothing on double click
- - Zoom with Ctrl + wheel
- - Move with wheel (shift changes the axes)
- */
- let svgZoom = d3.zoom()
- .on("zoom", event => {
- if (event.sourceEvent && event.sourceEvent.type === "wheel") {
- if (event.sourceEvent.wheelDelta < 0)
- svg.node().style.cursor = "zoom-out";
- else
- svg.node().style.cursor = "zoom-in";
- }
- svg.select("#vis").attr("transform", event.transform.toString());
- })
- .on("end", () => {
- svg.node().style.cursor = "";
- })
- .filter(event => event.type !== "dblclick" && (event.type === "wheel" ? event.ctrlKey : true))
- .touchable(() => ('ontouchstart' in window) || Boolean(window.TouchEvent));
- // @ts-ignore FIXME
- svg.select("rect").call(svgZoom);
-
-
- this.animateTree()
- window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", this.animateTree.bind(this))
- }
-
- componentDidUpdate(prevProps: Readonly, prevState: Readonly, snapshot?: any) {
- this.animateTree()
- }
+
+ {props.graph.nodes.filter(n => n.type === "family").map((r, i) =>
+ )}
+ {props.graph.nodes.filter(n => n.type === "etc").map((r, i) =>
+ )}
+ {props.graph.nodes.filter(n => n.type === "person").map((p, i) =>
+ )}
+
+
+
+ );
+}
- animateTree() {
- d3cola
- .flowLayout("x", d => d.target.type === "person" ? config.gridSize * 5 : config.gridSize * 3.5)
- .symmetricDiffLinkLengths(config.gridSize)
- .start(15, 0, 10);
-
- let nodesLayer = d3.select("#nodes");
- let linkLayer = d3.select("#links");
-
- let personNode = nodesLayer.selectAll(".person")
- .data(this.state.graph.nodes.filter(p => p.type === "person") as GraphPerson[])
- let partnerNode = nodesLayer.selectAll(".partnerNode")
- .data(this.state.graph.nodes.filter(node => node.type === "family"));
- let etcNode = nodesLayer.selectAll(".etc")
- .data(this.state.graph.nodes.filter(n => n.type === "etc"));
- let link = linkLayer.selectAll(".link")
- .data(this.state.graph.links);
-
- // reset style
- personNode.select(".bg").attr("style", null);
-
- const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
- switch (this.props.colorMode) {
- case ColorMode.NAME: {
- let last_names = viewGraph.nodes.filter(n => n.type === "person")
- .map((p: GraphPerson) => p.getName().split(" ").reverse()[0]);
- last_names = Array.from(new Set(last_names));
- const nameColor = d3.scaleOrdinal(last_names, d3.schemeSet3)
- personNode
- .select(".bg")
- .style("background-color", d => nameColor(d.getName().split(" ").reverse()[0]))
- .style("color", "black")
- personNode
- .select(".focused")
- .style("box-shadow", d => `0 0 1rem ${nameColor(d.getName().split(" ").reverse()[0])}`);
- break;
- }
- case ColorMode.AGE: {
- const ageColor = d3.scaleSequential()
- .domain([0, 120])
- .interpolator((d) => darkMode ? d3.interpolateYlGn(d) : d3.interpolateYlGn(1 - d))
- personNode
- .select(".bg")
- .style("background-color", (d: GraphPerson) => d.data.getLiving() ? ageColor(d.data.getAgeToday()) : "var(background-higher)")
- .style("color", (d: GraphPerson) =>
- (d.data.getAgeToday() < 70 && d.data.getLiving()) ? "var(--background)" : "var(--foreground)")
- .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : ageColor(d.data.getAgeToday()))
- .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid");
- personNode
- .select(".focused")
- .style("box-shadow", d => `0 0 1rem ${ageColor(d.data.getAgeToday())}`);
- break;
- }
- case ColorMode.GENDER: {
- const genderColor = d3.scaleOrdinal(["female", "male", "intersex", "unknown"], d3.schemeSet1);
- personNode
- .select(".bg")
- .style("background-color", (d: GraphPerson) => d.data.getLiving() ? genderColor(d.getGender()) : "var(--background-higher)")
- .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : genderColor(d.getGender()))
- .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid")
- .style("color", (d: GraphPerson) => d.data.getLiving() && matchMedia("(prefers-color-scheme: light)").matches ? "var(--background)" : "var(--foreground)")
- personNode
- .select(".focused")
- .style("box-shadow", d => `0 0 1rem ${genderColor(d.getGender())}`);
- break;
+function setupCola() {
+ let svg = d3.select("#family-tree");
+
+ const viewportSize = [svg.node().getBBox().width, svg.node().getBBox().height];
+ d3cola.size(viewportSize);
+
+ // catch the transformation events
+ /*
+ I have changed the default zoom behavior to the following one:
+ - Nothing on double click
+ - Zoom with Ctrl + wheel
+ - Move with wheel (shift changes the axes)
+ */
+ let svgZoom = d3.zoom()
+ .on("zoom", event => {
+ if (event.sourceEvent && event.sourceEvent.type === "wheel") {
+ if (event.sourceEvent.wheelDelta < 0)
+ svg.node().style.cursor = "zoom-out";
+ else
+ svg.node().style.cursor = "zoom-in";
}
+ svg.select("#vis").attr("transform", event.transform.toString());
+ })
+ .on("end", () => {
+ svg.node().style.cursor = "";
+ })
+ .filter(event => event.type !== "dblclick" && (event.type === "wheel" ? event.ctrlKey : true))
+ .touchable(() => ('ontouchstart' in window) || Boolean(window.TouchEvent));
+ // @ts-ignore FIXME
+ svg.select("rect").call(svgZoom);
+}
+
+function animateTree(graph: ViewGraph, colorMode: ColorMode) {
+ d3cola
+ .flowLayout("x", d => d.target.type === "person" ? config.gridSize * 5 : config.gridSize * 3.5)
+ .symmetricDiffLinkLengths(config.gridSize)
+ .start(15, 0, 10);
+
+ let nodesLayer = d3.select("#nodes");
+ let linkLayer = d3.select("#links");
+
+ let personNode = nodesLayer.selectAll(".person")
+ .data(graph.nodes.filter(p => p.type === "person") as GraphPerson[])
+ let partnerNode = nodesLayer.selectAll(".partnerNode")
+ .data(graph.nodes.filter(node => node.type === "family"));
+ let etcNode = nodesLayer.selectAll(".etc")
+ .data(graph.nodes.filter(n => n.type === "etc"));
+ let link = linkLayer.selectAll(".link")
+ .data(graph.links);
+
+ // reset style
+ personNode.select(".bg").attr("style", null);
+
+ const darkMode = window.matchMedia("(prefers-color-scheme: dark)").matches;
+ switch (colorMode) {
+ case ColorMode.NAME: {
+ let last_names = graph.nodes.filter(n => n.type === "person")
+ .map((p: GraphPerson) => p.getName().split(" ").reverse()[0]);
+ last_names = Array.from(new Set(last_names));
+ const nameColor = d3.scaleOrdinal(last_names, d3.schemeSet3)
+ personNode
+ .select(".bg")
+ .style("background-color", d => nameColor(d.getName().split(" ").reverse()[0]))
+ .style("color", "black")
+ personNode
+ .select(".focused")
+ .style("box-shadow", d => `0 0 1rem ${nameColor(d.getName().split(" ").reverse()[0])}`);
+ break;
+ }
+ case ColorMode.AGE: {
+ const ageColor = d3.scaleSequential()
+ .domain([0, 120])
+ .interpolator((d) => darkMode ? d3.interpolateYlGn(d) : d3.interpolateYlGn(1 - d))
+ personNode
+ .select(".bg")
+ .style("background-color", (d: GraphPerson) => d.data.getLiving() ? ageColor(d.data.getAgeToday()) : "var(background-higher)")
+ .style("color", (d: GraphPerson) =>
+ (d.data.getAgeToday() < 70 && d.data.getLiving()) ? "var(--background)" : "var(--foreground)")
+ .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : ageColor(d.data.getAgeToday()))
+ .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid");
+ personNode
+ .select(".focused")
+ .style("box-shadow", d => `0 0 1rem ${ageColor(d.data.getAgeToday())}`);
+ break;
}
+ case ColorMode.GENDER: {
+ const genderColor = d3.scaleOrdinal(["female", "male", "intersex", "unknown"], d3.schemeSet1);
+ personNode
+ .select(".bg")
+ .style("background-color", (d: GraphPerson) => d.data.getLiving() ? genderColor(d.getGender()) : "var(--background-higher)")
+ .style("border-color", (d: GraphPerson) => d.data.getLiving() ? "var(--background-higher)" : genderColor(d.getGender()))
+ .style("border-style", (d: GraphPerson) => d.data.getLiving() ? "" : "solid")
+ .style("color", (d: GraphPerson) => d.data.getLiving() && matchMedia("(prefers-color-scheme: light)").matches ? "var(--background)" : "var(--foreground)")
+ personNode
+ .select(".focused")
+ .style("box-shadow", d => `0 0 1rem ${genderColor(d.getGender())}`);
+ break;
+ }
+ }
+ personNode
+ .transition()
+ .duration(300)
+ .style("opacity", "1")
+
+ link
+ .transition()
+ .duration(600)
+ .style("opacity", "1")
+ etcNode
+ .transition()
+ .duration(300)
+ .style("opacity", "1")
+
+ d3cola.on("tick", () => {
personNode
- .transition()
- .duration(300)
- .style("opacity", "1")
-
- link
- .transition()
- .duration(600)
- .style("opacity", "1")
+ .attr("x", d => d.x - d.width / 2)
+ .attr("y", d => d.y - d.height / 2);
+ partnerNode
+ .attr("transform", d => "translate(" + d.x + "," + d.y + ")");
etcNode
- .transition()
- .duration(300)
- .style("opacity", "1")
-
- d3cola.on("tick", () => {
- personNode
- .attr("x", d => d.x - d.width / 2)
- .attr("y", d => d.y - d.height / 2);
- partnerNode
- .attr("transform", d => "translate(" + d.x + "," + d.y + ")");
- etcNode
- .attr("transform", d => "translate(" + d.x + "," + d.y + ")");
-
- link.attr("d", d => {
- // 1 or -1
- let flip = -(Number((d.source.y - d.target.y) > 0) * 2 - 1);
- let radius = Math.min(config.gridSize / 2, Math.abs(d.target.x - d.source.x) / 2, Math.abs(d.target.y - d.source.y) / 2);
-
- if (d.target.type === "person") {
- return `M${d.source.x},${d.source.y} ` +
- `h${config.gridSize} ` +
- `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` +
- `V${d.target.y - (flip) * radius} ` +
- `a${radius} ${radius} 0 0 ${(-flip + 1) / 2} ${radius} ${flip * radius} ` +
- `H${d.target.x}`;
- } else {
- return `M${d.source.x} ${d.source.y} ` +
- `H${d.target.x - radius} ` +
- `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` +
- `V${d.target.y}`;
- }
- });
+ .attr("transform", d => "translate(" + d.x + "," + d.y + ")");
+
+ link.attr("d", d => {
+ // 1 or -1
+ let flip = -(Number((d.source.y - d.target.y) > 0) * 2 - 1);
+ let radius = Math.min(config.gridSize / 2, Math.abs(d.target.x - d.source.x) / 2, Math.abs(d.target.y - d.source.y) / 2);
+
+ if (d.target.type === "person") {
+ return `M${d.source.x},${d.source.y} ` +
+ `h${config.gridSize} ` +
+ `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` +
+ `V${d.target.y - (flip) * radius} ` +
+ `a${radius} ${radius} 0 0 ${(-flip + 1) / 2} ${radius} ${flip * radius} ` +
+ `H${d.target.x}`;
+ } else {
+ return `M${d.source.x} ${d.source.y} ` +
+ `H${d.target.x - radius} ` +
+ `a${radius} ${radius} 0 0 ${(flip + 1) / 2} ${radius} ${flip * radius} ` +
+ `V${d.target.y}`;
+ }
});
- }
-
- onGraphChanged() {
- if (this.mounted) {
- this.setState({
- graph: viewGraph
- });
- }
- }
+ });
}
+
export default TreeView;
diff --git a/src/components/Uploader.tsx b/src/components/Uploader.tsx
deleted file mode 100644
index 31aa41c8..00000000
--- a/src/components/Uploader.tsx
+++ /dev/null
@@ -1,70 +0,0 @@
-import Article from "./Article";
-import Form from "./Form";
-import * as React from "react";
-import {translationToString} from "../main";
-
-function UploadForm(props) {
- return (
-