Added settings tab with color settings (#6)

This commit is contained in:
Przemek Wiech
2021-10-25 13:33:34 +02:00
parent c564d592ec
commit 3914226042
9 changed files with 234 additions and 32 deletions

6
CHANGELOG.md Normal file
View 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
View File

@@ -17408,9 +17408,9 @@
"dev": true "dev": true
}, },
"topola": { "topola": {
"version": "3.3.6", "version": "3.5.0",
"resolved": "https://registry.npmjs.org/topola/-/topola-3.3.6.tgz", "resolved": "https://registry.npmjs.org/topola/-/topola-3.5.0.tgz",
"integrity": "sha512-ZsmJz17htEBOrIo3aKHe7i4aM+Xl8XBHRXL3P7FpKHsyd8LrsuiNLjOVNMhW2qApk6TeMmhyU4OXq9cq0zNv0w==", "integrity": "sha512-w3PSG3nKgNpH3+ZiIzB5nTHaIj/mk+rVbtbQoLq9O6V2M4dBJGd+eiuUZ4BsNjbFeUR0iJ8zMYBegVRHHSg8fA==",
"requires": { "requires": {
"array-flat-polyfill": "^1.0.1", "array-flat-polyfill": "^1.0.1",
"d3-array": "^2.12.1", "d3-array": "^2.12.1",

View File

@@ -29,7 +29,7 @@
"react-router-dom": "^5.2.0", "react-router-dom": "^5.2.0",
"semantic-ui-css": "^2.4.1", "semantic-ui-css": "^2.4.1",
"semantic-ui-react": "^2.0.3", "semantic-ui-react": "^2.0.3",
"topola": "^3.3.6" "topola": "^3.5.0"
}, },
"devDependencies": { "devDependencies": {
"@types/array.prototype.flatmap": "^1.2.2", "@types/array.prototype.flatmap": "^1.2.2",

View File

@@ -1,16 +1,23 @@
import * as H from 'history'; import * as H from 'history';
import * as queryString from 'query-string'; import * as queryString from 'query-string';
import * as React from 'react'; import React from 'react';
import {analyticsEvent} from './util/analytics'; import {analyticsEvent} from './util/analytics';
import {Chart, ChartComponent, ChartType} from './chart'; import {Chart, ChartComponent, ChartType} from './chart';
import {
argsToConfig,
Config,
ConfigPanel,
configToArgs,
DEFALUT_CONFIG,
} from './config';
import {DataSourceEnum, SourceSelection} from './datasource/data_source'; import {DataSourceEnum, SourceSelection} from './datasource/data_source';
import {Details} from './details'; import {Details} from './details';
import {EmbeddedDataSource, EmbeddedSourceSpec} from './datasource/embedded'; 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 {getI18nMessage} from './util/error_i18n';
import {IndiInfo} from 'topola'; import {IndiInfo} from 'topola';
import {Intro} from './intro'; 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 {Media} from './util/media';
import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom'; import {Redirect, Route, RouteComponentProps, Switch} from 'react-router-dom';
import {TopBar} from './menu/top_bar'; import {TopBar} from './menu/top_bar';
@@ -80,7 +87,10 @@ type DataSourceSpec =
| WikiTreeSourceSpec | WikiTreeSourceSpec
| EmbeddedSourceSpec; | 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 { interface Arguments {
sourceSpec?: DataSourceSpec; sourceSpec?: DataSourceSpec;
selection?: IndiInfo; selection?: IndiInfo;
@@ -88,6 +98,7 @@ interface Arguments {
standalone: boolean; standalone: boolean;
freezeAnimation?: boolean; freezeAnimation?: boolean;
showSidePanel: boolean; showSidePanel: boolean;
config: Config;
} }
/** /**
@@ -148,6 +159,7 @@ function getArguments(location: H.Location<any>): Arguments {
showSidePanel: getParam('sidePanel') !== 'false', // True by default. showSidePanel: getParam('sidePanel') !== 'false', // True by default.
standalone: getParam('standalone') !== 'false' && !embedded, standalone: getParam('standalone') !== 'false' && !embedded,
freezeAnimation: getParam('freeze') === 'true', // False by default freezeAnimation: getParam('freeze') === 'true', // False by default
config: argsToConfig(search),
}; };
} }
@@ -185,6 +197,7 @@ interface State {
sourceSpec?: DataSourceSpec; sourceSpec?: DataSourceSpec;
/** Freeze animations after initial chart render. */ /** Freeze animations after initial chart render. */
freezeAnimation?: boolean; freezeAnimation?: boolean;
config: Config;
} }
class AppComponent extends React.Component< class AppComponent extends React.Component<
@@ -196,6 +209,7 @@ class AppComponent extends React.Component<
standalone: true, standalone: true,
chartType: ChartType.Hourglass, chartType: ChartType.Hourglass,
showErrorPopup: false, showErrorPopup: false,
config: DEFALUT_CONFIG,
}; };
chartRef: ChartComponent | null = null; chartRef: ChartComponent | null = null;
@@ -313,6 +327,7 @@ class AppComponent extends React.Component<
selection: args.selection, selection: args.selection,
standalone: args.standalone, standalone: args.standalone,
chartType: args.chartType, chartType: args.chartType,
config: args.config,
}), }),
); );
try { 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. * Called when the user clicks an individual box in the chart.
* Updates the browser URL. * Updates the browser URL.
@@ -384,12 +409,10 @@ class AppComponent extends React.Component<
return; return;
} }
analyticsEvent('selection_changed'); analyticsEvent('selection_changed');
const location = this.props.location; this.updateUrl({
const search = queryString.parse(location.search); indi: selection.id,
search.indi = selection.id; gen: selection.generation,
search.gen = String(selection.generation); });
location.search = queryString.stringify(search);
this.props.history.push(location);
}; };
private onPrint = () => { private onPrint = () => {
@@ -460,6 +483,35 @@ class AppComponent extends React.Component<
switch (this.state.state) { switch (this.state.state) {
case AppState.SHOWING_CHART: case AppState.SHOWING_CHART:
case AppState.LOADING_MORE: 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 ( return (
<div id="content"> <div id="content">
<ErrorPopup <ErrorPopup
@@ -476,14 +528,12 @@ class AppComponent extends React.Component<
chartType={this.state.chartType} chartType={this.state.chartType}
onSelection={this.onSelection} onSelection={this.onSelection}
freezeAnimation={this.state.freezeAnimation} freezeAnimation={this.state.freezeAnimation}
colors={this.state.config.color}
ref={(ref) => (this.chartRef = ref)} ref={(ref) => (this.chartRef = ref)}
/> />
{this.state.showSidePanel ? ( {this.state.showSidePanel ? (
<Media at="large" className="sidePanel"> <Media at="large" className="sidePanel">
<Details <Tab panes={sidePanelTabs} />
gedcom={this.state.data!.gedcom}
indi={this.state.selection!.id}
/>
</Media> </Media>
) : null} ) : null}
</div> </div>

View File

@@ -1,4 +1,5 @@
import * as React from 'react'; import * as React from 'react';
import {ChartColors} from './config';
import {injectIntl, WrappedComponentProps} from 'react-intl'; import {injectIntl, WrappedComponentProps} from 'react-intl';
import {interpolateNumber} from 'd3-interpolate'; import {interpolateNumber} from 'd3-interpolate';
import {max, min} from 'd3-array'; import {max, min} from 'd3-array';
@@ -23,6 +24,7 @@ import {
RelativesChart, RelativesChart,
FancyChart, FancyChart,
CircleRenderer, CircleRenderer,
ChartColors as TopolaChartColors,
} from 'topola'; } from 'topola';
/** How much to zoom when using the +/- buttons. */ /** How much to zoom when using the +/- buttons. */
@@ -140,12 +142,19 @@ export enum ChartType {
Fancy, 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 { export interface ChartProps {
data: JsonGedcomData; data: JsonGedcomData;
selection: IndiInfo; selection: IndiInfo;
chartType: ChartType; chartType: ChartType;
onSelection: (indiInfo: IndiInfo) => void; onSelection: (indiInfo: IndiInfo) => void;
freezeAnimation?: boolean; freezeAnimation?: boolean;
colors?: ChartColors;
} }
/** Component showing the genealogy chart and handling transition animations. */ /** 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 * If indiInfo is not given, it means that it is the initial render and no
* animation is performed. * 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. // Wait for animation to finish if animation is in progress.
if (!args.initialRender && this.animating) { if (!args.initialRender && this.animating) {
this.rerenderRequired = true; this.rerenderRequired = true;
@@ -215,6 +229,7 @@ export class ChartComponent extends React.PureComponent<
renderer: this.getRendererType(), renderer: this.getRendererType(),
svgSelector: '#chart', svgSelector: '#chart',
indiCallback: (info) => this.props.onSelection(info), indiCallback: (info) => this.props.onSelection(info),
colors: chartColors.get(this.props.colors!),
animate: true, animate: true,
updateSvgSize: false, updateSvgSize: false,
locale: this.props.intl.locale, locale: this.props.intl.locale,
@@ -277,6 +292,7 @@ export class ChartComponent extends React.PureComponent<
.attr('transform', `translate(${offsetX}, ${offsetY})`) .attr('transform', `translate(${offsetX}, ${offsetY})`)
.attr('width', chartInfo.size[0] * scale) .attr('width', chartInfo.size[0] * scale)
.attr('height', chartInfo.size[1] * scale); .attr('height', chartInfo.size[1] * scale);
if (args.resetPosition) {
if (args.initialRender) { if (args.initialRender) {
parent.scrollLeft = -dx; parent.scrollLeft = -dx;
parent.scrollTop = -dy; parent.scrollTop = -dy;
@@ -285,6 +301,7 @@ export class ChartComponent extends React.PureComponent<
.tween('scrollLeft', scrollLeftTween(-dx)) .tween('scrollLeft', scrollLeftTween(-dx))
.tween('scrollTop', scrollTopTween(-dy)); .tween('scrollTop', scrollTopTween(-dy));
} }
}
// After the animation is finished, rerender the chart if required. // After the animation is finished, rerender the chart if required.
this.animating = true; this.animating = true;
@@ -292,18 +309,21 @@ export class ChartComponent extends React.PureComponent<
this.animating = false; this.animating = false;
if (this.rerenderRequired) { if (this.rerenderRequired) {
this.rerenderRequired = false; this.rerenderRequired = false;
this.renderChart({initialRender: false}); this.renderChart({initialRender: false, resetPosition: false});
} }
}); });
} }
componentDidMount() { componentDidMount() {
this.renderChart({initialRender: true}); this.renderChart({initialRender: true, resetPosition: true});
} }
componentDidUpdate(prevProps: ChartProps) { componentDidUpdate(prevProps: ChartProps) {
const initialRender = this.props.chartType !== prevProps.chartType; const initialRender =
this.renderChart({initialRender}); this.props.chartType !== prevProps.chartType ||
this.props.colors !== prevProps.colors;
const resetPosition = this.props.chartType !== prevProps.chartType;
this.renderChart({initialRender, resetPosition});
} }
render() { render() {

107
src/config.tsx Normal file
View 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>
</>
);
}

View File

@@ -221,7 +221,7 @@ class DetailsComponent extends React.Component<
.filter(hasData); .filter(hasData);
return ( return (
<div className="ui segments" id="details"> <div className="ui segments details">
{getDetails(entries, ['NAME'], nameDetails)} {getDetails(entries, ['NAME'], nameDetails)}
{getDetails(entries, EVENT_TAGS, (entry) => {getDetails(entries, EVENT_TAGS, (entry) =>
eventDetails(entry, this.props.intl), eventDetails(entry, this.props.intl),

View File

@@ -141,3 +141,16 @@ div.zoom {
.ui.top.attached.menu { .ui.top.attached.menu {
margin-top: 0px; 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;
}

View File

@@ -70,5 +70,11 @@
"error.WIKITREE_ID_NOT_PROVIDED": "Identyfikator WikiTree nie został podany", "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_ACCESSIBLE": "Profil WikiTree {id} nie jest dostępny",
"error.WIKITREE_PROFILE_NOT_FOUND": "Profil WikiTree {id} nie istnieje", "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"
} }