mirror of
https://github.com/PeWu/topola-viewer.git
synced 2025-12-24 03:00:05 +00:00
Added settings tab with color settings (#6)
This commit is contained in:
parent
c564d592ec
commit
3914226042
6
CHANGELOG.md
Normal file
6
CHANGELOG.md
Normal file
@ -0,0 +1,6 @@
|
||||
# Changelog
|
||||
|
||||
## 2021-10-25
|
||||
|
||||
- Added "Settings" tab in side panel
|
||||
- Added color settings (none, by generation, by sex)
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@ -17408,9 +17408,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"topola": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/topola/-/topola-3.3.6.tgz",
|
||||
"integrity": "sha512-ZsmJz17htEBOrIo3aKHe7i4aM+Xl8XBHRXL3P7FpKHsyd8LrsuiNLjOVNMhW2qApk6TeMmhyU4OXq9cq0zNv0w==",
|
||||
"version": "3.5.0",
|
||||
"resolved": "https://registry.npmjs.org/topola/-/topola-3.5.0.tgz",
|
||||
"integrity": "sha512-w3PSG3nKgNpH3+ZiIzB5nTHaIj/mk+rVbtbQoLq9O6V2M4dBJGd+eiuUZ4BsNjbFeUR0iJ8zMYBegVRHHSg8fA==",
|
||||
"requires": {
|
||||
"array-flat-polyfill": "^1.0.1",
|
||||
"d3-array": "^2.12.1",
|
||||
|
||||
@ -29,7 +29,7 @@
|
||||
"react-router-dom": "^5.2.0",
|
||||
"semantic-ui-css": "^2.4.1",
|
||||
"semantic-ui-react": "^2.0.3",
|
||||
"topola": "^3.3.6"
|
||||
"topola": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/array.prototype.flatmap": "^1.2.2",
|
||||
|
||||
78
src/app.tsx
78
src/app.tsx
@ -1,16 +1,23 @@
|
||||
import * as H from 'history';
|
||||
import * as queryString from 'query-string';
|
||||
import * as React from 'react';
|
||||
import React from 'react';
|
||||
import {analyticsEvent} from './util/analytics';
|
||||
import {Chart, ChartComponent, ChartType} from './chart';
|
||||
import {
|
||||
argsToConfig,
|
||||
Config,
|
||||
ConfigPanel,
|
||||
configToArgs,
|
||||
DEFALUT_CONFIG,
|
||||
} from './config';
|
||||
import {DataSourceEnum, SourceSelection} from './datasource/data_source';
|
||||
import {Details} from './details';
|
||||
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded';
|
||||
import {FormattedMessage, WrappedComponentProps, injectIntl} from 'react-intl';
|
||||
import {FormattedMessage, injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import {getI18nMessage} from './util/error_i18n';
|
||||
import {IndiInfo} from 'topola';
|
||||
import {Intro} from './intro';
|
||||
import {Loader, Message, Portal} from 'semantic-ui-react';
|
||||
import {Loader, Message, Portal, Tab} from 'semantic-ui-react';
|
||||
import {Media} from './util/media';
|
||||
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
|
||||
import {TopBar} from './menu/top_bar';
|
||||
@ -80,7 +87,10 @@ type DataSourceSpec =
|
||||
| WikiTreeSourceSpec
|
||||
| EmbeddedSourceSpec;
|
||||
|
||||
/** Arguments passed to the application, primarily through URL parameters. */
|
||||
/**
|
||||
* Arguments passed to the application, primarily through URL parameters.
|
||||
* Non-optional arguments get populated with default values.
|
||||
*/
|
||||
interface Arguments {
|
||||
sourceSpec?: DataSourceSpec;
|
||||
selection?: IndiInfo;
|
||||
@ -88,6 +98,7 @@ interface Arguments {
|
||||
standalone: boolean;
|
||||
freezeAnimation?: boolean;
|
||||
showSidePanel: boolean;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -148,6 +159,7 @@ function getArguments(location: H.Location<any>): Arguments {
|
||||
showSidePanel: getParam('sidePanel') !== 'false', // True by default.
|
||||
standalone: getParam('standalone') !== 'false' && !embedded,
|
||||
freezeAnimation: getParam('freeze') === 'true', // False by default
|
||||
config: argsToConfig(search),
|
||||
};
|
||||
}
|
||||
|
||||
@ -185,6 +197,7 @@ interface State {
|
||||
sourceSpec?: DataSourceSpec;
|
||||
/** Freeze animations after initial chart render. */
|
||||
freezeAnimation?: boolean;
|
||||
config: Config;
|
||||
}
|
||||
|
||||
class AppComponent extends React.Component<
|
||||
@ -196,6 +209,7 @@ class AppComponent extends React.Component<
|
||||
standalone: true,
|
||||
chartType: ChartType.Hourglass,
|
||||
showErrorPopup: false,
|
||||
config: DEFALUT_CONFIG,
|
||||
};
|
||||
chartRef: ChartComponent | null = null;
|
||||
|
||||
@ -313,6 +327,7 @@ class AppComponent extends React.Component<
|
||||
selection: args.selection,
|
||||
standalone: args.standalone,
|
||||
chartType: args.chartType,
|
||||
config: args.config,
|
||||
}),
|
||||
);
|
||||
try {
|
||||
@ -374,6 +389,16 @@ class AppComponent extends React.Component<
|
||||
}
|
||||
}
|
||||
|
||||
private updateUrl(args: queryString.ParsedQuery<any>) {
|
||||
const location = this.props.location;
|
||||
const search = queryString.parse(location.search);
|
||||
for (const key in args) {
|
||||
search[key] = args[key];
|
||||
}
|
||||
location.search = queryString.stringify(search);
|
||||
this.props.history.push(location);
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user clicks an individual box in the chart.
|
||||
* Updates the browser URL.
|
||||
@ -384,12 +409,10 @@ class AppComponent extends React.Component<
|
||||
return;
|
||||
}
|
||||
analyticsEvent('selection_changed');
|
||||
const location = this.props.location;
|
||||
const search = queryString.parse(location.search);
|
||||
search.indi = selection.id;
|
||||
search.gen = String(selection.generation);
|
||||
location.search = queryString.stringify(search);
|
||||
this.props.history.push(location);
|
||||
this.updateUrl({
|
||||
indi: selection.id,
|
||||
gen: selection.generation,
|
||||
});
|
||||
};
|
||||
|
||||
private onPrint = () => {
|
||||
@ -460,6 +483,35 @@ class AppComponent extends React.Component<
|
||||
switch (this.state.state) {
|
||||
case AppState.SHOWING_CHART:
|
||||
case AppState.LOADING_MORE:
|
||||
const sidePanelTabs = [
|
||||
{
|
||||
menuItem: this.props.intl.formatMessage({
|
||||
id: 'tab.info',
|
||||
defaultMessage: 'Info',
|
||||
}),
|
||||
render: () => (
|
||||
<Details
|
||||
gedcom={this.state.data!.gedcom}
|
||||
indi={this.state.selection!.id}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
menuItem: this.props.intl.formatMessage({
|
||||
id: 'tab.settings',
|
||||
defaultMessage: 'Settings',
|
||||
}),
|
||||
render: () => (
|
||||
<ConfigPanel
|
||||
config={this.state.config}
|
||||
onChange={(config) => {
|
||||
this.setState(Object.assign({}, this.state, {config}));
|
||||
this.updateUrl(configToArgs(config));
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
];
|
||||
return (
|
||||
<div id="content">
|
||||
<ErrorPopup
|
||||
@ -476,14 +528,12 @@ class AppComponent extends React.Component<
|
||||
chartType={this.state.chartType}
|
||||
onSelection={this.onSelection}
|
||||
freezeAnimation={this.state.freezeAnimation}
|
||||
colors={this.state.config.color}
|
||||
ref={(ref) => (this.chartRef = ref)}
|
||||
/>
|
||||
{this.state.showSidePanel ? (
|
||||
<Media at="large" className="sidePanel">
|
||||
<Details
|
||||
gedcom={this.state.data!.gedcom}
|
||||
indi={this.state.selection!.id}
|
||||
/>
|
||||
<Tab panes={sidePanelTabs} />
|
||||
</Media>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
import * as React from 'react';
|
||||
import {ChartColors} from './config';
|
||||
import {injectIntl, WrappedComponentProps} from 'react-intl';
|
||||
import {interpolateNumber} from 'd3-interpolate';
|
||||
import {max, min} from 'd3-array';
|
||||
@ -23,6 +24,7 @@ import {
|
||||
RelativesChart,
|
||||
FancyChart,
|
||||
CircleRenderer,
|
||||
ChartColors as TopolaChartColors,
|
||||
} from 'topola';
|
||||
|
||||
/** How much to zoom when using the +/- buttons. */
|
||||
@ -140,12 +142,19 @@ export enum ChartType {
|
||||
Fancy,
|
||||
}
|
||||
|
||||
const chartColors = new Map<ChartColors, TopolaChartColors>([
|
||||
[ChartColors.NO_COLOR, TopolaChartColors.NO_COLOR],
|
||||
[ChartColors.COLOR_BY_GENERATION, TopolaChartColors.COLOR_BY_GENERATION],
|
||||
[ChartColors.COLOR_BY_SEX, TopolaChartColors.COLOR_BY_SEX],
|
||||
]);
|
||||
|
||||
export interface ChartProps {
|
||||
data: JsonGedcomData;
|
||||
selection: IndiInfo;
|
||||
chartType: ChartType;
|
||||
onSelection: (indiInfo: IndiInfo) => void;
|
||||
freezeAnimation?: boolean;
|
||||
colors?: ChartColors;
|
||||
}
|
||||
|
||||
/** Component showing the genealogy chart and handling transition animations. */
|
||||
@ -195,7 +204,12 @@ export class ChartComponent extends React.PureComponent<
|
||||
* If indiInfo is not given, it means that it is the initial render and no
|
||||
* animation is performed.
|
||||
*/
|
||||
private renderChart(args: {initialRender: boolean} = {initialRender: false}) {
|
||||
private renderChart(
|
||||
args: {initialRender: boolean; resetPosition: boolean} = {
|
||||
initialRender: false,
|
||||
resetPosition: false,
|
||||
},
|
||||
) {
|
||||
// Wait for animation to finish if animation is in progress.
|
||||
if (!args.initialRender && this.animating) {
|
||||
this.rerenderRequired = true;
|
||||
@ -215,6 +229,7 @@ export class ChartComponent extends React.PureComponent<
|
||||
renderer: this.getRendererType(),
|
||||
svgSelector: '#chart',
|
||||
indiCallback: (info) => this.props.onSelection(info),
|
||||
colors: chartColors.get(this.props.colors!),
|
||||
animate: true,
|
||||
updateSvgSize: false,
|
||||
locale: this.props.intl.locale,
|
||||
@ -277,13 +292,15 @@ export class ChartComponent extends React.PureComponent<
|
||||
.attr('transform', `translate(${offsetX}, ${offsetY})`)
|
||||
.attr('width', chartInfo.size[0] * scale)
|
||||
.attr('height', chartInfo.size[1] * scale);
|
||||
if (args.initialRender) {
|
||||
parent.scrollLeft = -dx;
|
||||
parent.scrollTop = -dy;
|
||||
} else {
|
||||
svgTransition
|
||||
.tween('scrollLeft', scrollLeftTween(-dx))
|
||||
.tween('scrollTop', scrollTopTween(-dy));
|
||||
if (args.resetPosition) {
|
||||
if (args.initialRender) {
|
||||
parent.scrollLeft = -dx;
|
||||
parent.scrollTop = -dy;
|
||||
} else {
|
||||
svgTransition
|
||||
.tween('scrollLeft', scrollLeftTween(-dx))
|
||||
.tween('scrollTop', scrollTopTween(-dy));
|
||||
}
|
||||
}
|
||||
|
||||
// After the animation is finished, rerender the chart if required.
|
||||
@ -292,18 +309,21 @@ export class ChartComponent extends React.PureComponent<
|
||||
this.animating = false;
|
||||
if (this.rerenderRequired) {
|
||||
this.rerenderRequired = false;
|
||||
this.renderChart({initialRender: false});
|
||||
this.renderChart({initialRender: false, resetPosition: false});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
this.renderChart({initialRender: true});
|
||||
this.renderChart({initialRender: true, resetPosition: true});
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps: ChartProps) {
|
||||
const initialRender = this.props.chartType !== prevProps.chartType;
|
||||
this.renderChart({initialRender});
|
||||
const initialRender =
|
||||
this.props.chartType !== prevProps.chartType ||
|
||||
this.props.colors !== prevProps.colors;
|
||||
const resetPosition = this.props.chartType !== prevProps.chartType;
|
||||
this.renderChart({initialRender, resetPosition});
|
||||
}
|
||||
|
||||
render() {
|
||||
|
||||
107
src/config.tsx
Normal file
107
src/config.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
import {Checkbox, Form} from 'semantic-ui-react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {ParsedQuery} from 'query-string';
|
||||
|
||||
export enum ChartColors {
|
||||
NO_COLOR,
|
||||
COLOR_BY_SEX,
|
||||
COLOR_BY_GENERATION,
|
||||
}
|
||||
|
||||
export interface Config {
|
||||
color: ChartColors;
|
||||
}
|
||||
|
||||
export const DEFALUT_CONFIG: Config = {
|
||||
color: ChartColors.COLOR_BY_GENERATION,
|
||||
};
|
||||
|
||||
const COLOR_ARG = new Map<string, ChartColors>([
|
||||
['n', ChartColors.NO_COLOR],
|
||||
['g', ChartColors.COLOR_BY_GENERATION],
|
||||
['s', ChartColors.COLOR_BY_SEX],
|
||||
]);
|
||||
const COLOR_ARG_INVERSE = new Map<ChartColors, string>();
|
||||
COLOR_ARG.forEach((v, k) => COLOR_ARG_INVERSE.set(v, k));
|
||||
|
||||
export function argsToConfig(args: ParsedQuery<any>): Config {
|
||||
const getParam = (name: string) => {
|
||||
const value = args[name];
|
||||
return typeof value === 'string' ? value : undefined;
|
||||
};
|
||||
|
||||
return {
|
||||
color: COLOR_ARG.get(getParam('c') ?? '') ?? DEFALUT_CONFIG.color,
|
||||
};
|
||||
}
|
||||
|
||||
export function configToArgs(config: Config): ParsedQuery<any> {
|
||||
return {c: COLOR_ARG_INVERSE.get(config.color)};
|
||||
}
|
||||
|
||||
export function ConfigPanel(props: {
|
||||
config: Config;
|
||||
onChange: (config: Config) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<Form className="ui segments details">
|
||||
<div className="ui segment">
|
||||
<div className="ui sub header">
|
||||
<FormattedMessage id="config.colors" defaultMessage="Colors" />
|
||||
</div>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.colors.NO_COLOR"
|
||||
defaultMessage="none"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="none"
|
||||
checked={props.config.color === ChartColors.NO_COLOR}
|
||||
onClick={() => props.onChange({color: ChartColors.NO_COLOR})}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.colors.COLOR_BY_GENERATION"
|
||||
defaultMessage="by generation"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="generation"
|
||||
checked={props.config.color === ChartColors.COLOR_BY_GENERATION}
|
||||
onClick={() =>
|
||||
props.onChange({color: ChartColors.COLOR_BY_GENERATION})
|
||||
}
|
||||
/>
|
||||
</Form.Field>
|
||||
<Form.Field className="no-margin">
|
||||
<Checkbox
|
||||
radio
|
||||
label={
|
||||
<FormattedMessage
|
||||
tagName="label"
|
||||
id="config.colors.COLOR_BY_SEX"
|
||||
defaultMessage="by sex"
|
||||
/>
|
||||
}
|
||||
name="checkboxRadioGroup"
|
||||
value="gender"
|
||||
checked={props.config.color === ChartColors.COLOR_BY_SEX}
|
||||
onClick={() => props.onChange({color: ChartColors.COLOR_BY_SEX})}
|
||||
/>
|
||||
</Form.Field>
|
||||
</div>
|
||||
</Form>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@ -221,7 +221,7 @@ class DetailsComponent extends React.Component<
|
||||
.filter(hasData);
|
||||
|
||||
return (
|
||||
<div className="ui segments" id="details">
|
||||
<div className="ui segments details">
|
||||
{getDetails(entries, ['NAME'], nameDetails)}
|
||||
{getDetails(entries, EVENT_TAGS, (entry) =>
|
||||
eventDetails(entry, this.props.intl),
|
||||
|
||||
@ -141,3 +141,16 @@ div.zoom {
|
||||
.ui.top.attached.menu {
|
||||
margin-top: 0px;
|
||||
}
|
||||
|
||||
.ui.segments.details {
|
||||
margin: 0px !important;
|
||||
border: 0px !important;
|
||||
}
|
||||
|
||||
.ui.form .field.no-margin {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.ui.tabular.menu a {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
@ -70,5 +70,11 @@
|
||||
"error.WIKITREE_ID_NOT_PROVIDED": "Identyfikator WikiTree nie został podany",
|
||||
"error.WIKITREE_PROFILE_NOT_ACCESSIBLE": "Profil WikiTree {id} nie jest dostępny",
|
||||
"error.WIKITREE_PROFILE_NOT_FOUND": "Profil WikiTree {id} nie istnieje",
|
||||
"wikitree.private": "Prywatne"
|
||||
"wikitree.private": "Prywatne",
|
||||
"tab.info": "Info",
|
||||
"tab.settings": "Ustawienia",
|
||||
"config.colors": "Kolory",
|
||||
"config.colors.NO_COLOR": "brak",
|
||||
"config.colors.COLOR_BY_GENERATION": "według pokolenia",
|
||||
"config.colors.COLOR_BY_SEX": "według płci"
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user