mirror of
https://github.com/maputnik/editor.git
synced 2026-07-18 20:07:27 +00:00
f14eeae38b
## Launch Checklist This PR increases coverage by adding unit tests to lib folde, replace the skipped end to end placeholder with actual tests and adds more end to end tests. This was mostly done by AI (Claude opus 4.8) and I reviewed it and requested changes where needed. - [x] Briefly describe the changes in this PR. - [x] Link to related issues. - [x] Include before/after visuals or gifs if this PR includes visual changes. - [x] Write tests for all new functionality. - [x] Add an entry to `CHANGELOG.md` under the `## main` section. --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
import React from "react";
|
|
import {PiListPlusBold} from "react-icons/pi";
|
|
import {TbMathFunction} from "react-icons/tb";
|
|
import latest from "@maplibre/maplibre-gl-style-spec/dist/latest.json";
|
|
|
|
import { InputButton } from "./InputButton";
|
|
import { InputSpec } from "./InputSpec";
|
|
import { InputNumber } from "./InputNumber";
|
|
import { InputString } from "./InputString";
|
|
import { InputSelect } from "./InputSelect";
|
|
import { Block } from "./Block";
|
|
import { generateUniqueId as docUid } from "../libs/document-uid";
|
|
import { sortNumerically } from "../libs/sort-numerically";
|
|
import {findDefaultFromSpec} from "../libs/spec-helper";
|
|
import { type WithTranslation, withTranslation } from "react-i18next";
|
|
|
|
import { labelFromFieldName } from "../libs/label-from-field-name";
|
|
import { DeleteStopButton } from "./DeleteStopButton";
|
|
import { type MappedLayerErrors } from "../libs/definitions";
|
|
|
|
|
|
|
|
function setStopRefs(props: DataPropertyInternalProps, state: DataPropertyState) {
|
|
// This is initialised below only if required to improved performance.
|
|
let newRefs: {[key: number]: string} | undefined;
|
|
|
|
if(props.value && props.value.stops) {
|
|
props.value.stops.forEach((_val, idx) => {
|
|
if(!Object.prototype.hasOwnProperty.call(state.refs, idx)) {
|
|
if(!newRefs) {
|
|
newRefs = {...state};
|
|
}
|
|
newRefs[idx] = docUid("stop-");
|
|
}
|
|
});
|
|
}
|
|
|
|
return newRefs;
|
|
}
|
|
|
|
type DataPropertyInternalProps = {
|
|
onChange?(fieldName: string, value: any): unknown
|
|
onDeleteStop?(...args: unknown[]): unknown
|
|
onAddStop?(...args: unknown[]): unknown
|
|
onExpressionClick?(...args: unknown[]): unknown
|
|
onChangeToZoomFunction?(...args: unknown[]): unknown
|
|
fieldName: string
|
|
fieldType?: string
|
|
fieldSpec?: object
|
|
value?: DataPropertyValue
|
|
errors?: MappedLayerErrors
|
|
} & WithTranslation;
|
|
|
|
type DataPropertyState = {
|
|
refs: {[key: number]: string}
|
|
};
|
|
|
|
type DataPropertyValue = {
|
|
default?: any
|
|
property?: string
|
|
base?: number
|
|
type?: string
|
|
stops: Stop[]
|
|
};
|
|
|
|
export type Stop = [{
|
|
zoom: number
|
|
value: number
|
|
}, number];
|
|
|
|
class DataPropertyInternal extends React.Component<DataPropertyInternalProps, DataPropertyState> {
|
|
state = {
|
|
refs: {} as {[key: number]: string}
|
|
};
|
|
|
|
componentDidMount() {
|
|
const newRefs = setStopRefs(this.props, this.state);
|
|
|
|
if(newRefs) {
|
|
this.setState({
|
|
refs: newRefs
|
|
});
|
|
}
|
|
}
|
|
|
|
static getDerivedStateFromProps(props: Readonly<DataPropertyInternalProps>, state: DataPropertyState) {
|
|
const newRefs = setStopRefs(props, state);
|
|
if(newRefs) {
|
|
return {
|
|
refs: newRefs
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
getFieldFunctionType(fieldSpec: any) {
|
|
if (fieldSpec.expression.interpolated) {
|
|
return "exponential";
|
|
}
|
|
if (fieldSpec.type === "number") {
|
|
return "interval";
|
|
}
|
|
return "categorical";
|
|
}
|
|
|
|
getDataFunctionTypes(fieldSpec: any) {
|
|
if (fieldSpec.expression.interpolated) {
|
|
return ["interpolate", "categorical", "interval", "exponential", "identity"];
|
|
}
|
|
else {
|
|
return ["categorical", "interval", "identity"];
|
|
}
|
|
}
|
|
|
|
// Order the stops altering the refs to reflect their new position.
|
|
orderStopsByZoom(stops: Stop[]) {
|
|
const mappedWithRef = stops
|
|
.map((stop, idx) => {
|
|
return {
|
|
ref: this.state.refs[idx],
|
|
data: stop
|
|
};
|
|
})
|
|
// Sort by zoom
|
|
.sort((a, b) => sortNumerically(a.data[0].zoom, b.data[0].zoom));
|
|
|
|
// Fetch the new position of the stops
|
|
const newRefs = {} as {[key: number]: string};
|
|
mappedWithRef
|
|
.forEach((stop, idx) =>{
|
|
newRefs[idx] = stop.ref;
|
|
});
|
|
|
|
this.setState({
|
|
refs: newRefs
|
|
});
|
|
|
|
return mappedWithRef.map((item) => item.data);
|
|
}
|
|
|
|
onChange = (fieldName: string, value: any) => {
|
|
if (value.type === "identity") {
|
|
value = {
|
|
type: value.type,
|
|
property: value.property,
|
|
};
|
|
}
|
|
else {
|
|
const stopValue = value.type === "categorical" ? "" : 0;
|
|
value = {
|
|
property: "",
|
|
type: value.type,
|
|
// Default props if they don't already exist.
|
|
stops: [
|
|
[{zoom: 6, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec as any)],
|
|
[{zoom: 10, value: stopValue}, findDefaultFromSpec(this.props.fieldSpec as any)]
|
|
],
|
|
...value,
|
|
};
|
|
}
|
|
this.props.onChange!(fieldName, value);
|
|
};
|
|
|
|
changeStop(changeIdx: number, stopData: { zoom: number | undefined, value: number }, value: number) {
|
|
const stops = this.props.value?.stops.slice(0) || [];
|
|
// const changedStop = stopData.zoom === undefined ? stopData.value : stopData
|
|
stops[changeIdx] = [
|
|
{
|
|
value: stopData.value,
|
|
zoom: (stopData.zoom === undefined) ? 0 : stopData.zoom,
|
|
},
|
|
value
|
|
];
|
|
|
|
const orderedStops = this.orderStopsByZoom(stops);
|
|
|
|
const changedValue = {
|
|
...this.props.value,
|
|
stops: orderedStops,
|
|
};
|
|
this.onChange(this.props.fieldName, changedValue);
|
|
}
|
|
|
|
changeBase(newValue: number | undefined) {
|
|
const changedValue = {
|
|
...this.props.value,
|
|
base: newValue
|
|
};
|
|
|
|
if (changedValue.base === undefined) {
|
|
delete changedValue["base"];
|
|
}
|
|
this.props.onChange!(this.props.fieldName, changedValue);
|
|
}
|
|
|
|
changeDataType(propVal: string) {
|
|
if (propVal === "interpolate" && this.props.onChangeToZoomFunction) {
|
|
this.props.onChangeToZoomFunction();
|
|
}
|
|
else {
|
|
this.onChange(this.props.fieldName, {
|
|
...this.props.value,
|
|
type: propVal,
|
|
});
|
|
}
|
|
}
|
|
|
|
changeDataProperty(propName: "property" | "default", propVal: any) {
|
|
if (propVal) {
|
|
this.props.value![propName] = propVal;
|
|
}
|
|
else {
|
|
delete this.props.value![propName];
|
|
}
|
|
this.onChange(this.props.fieldName, this.props.value);
|
|
}
|
|
|
|
render() {
|
|
const t = this.props.t;
|
|
|
|
if (typeof this.props.value?.type === "undefined") {
|
|
this.props.value!.type = this.getFieldFunctionType(this.props.fieldSpec);
|
|
}
|
|
|
|
let dataFields;
|
|
if (this.props.value?.stops) {
|
|
dataFields = this.props.value.stops.map((stop, idx) => {
|
|
const zoomLevel = typeof stop[0] === "object" ? stop[0].zoom : undefined;
|
|
const key = this.state.refs[idx];
|
|
const dataLevel = typeof stop[0] === "object" ? stop[0].value : stop[0];
|
|
const value = stop[1];
|
|
const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop?.bind(this, idx)} />;
|
|
|
|
const dataProps = {
|
|
"aria-label": t("Input value"),
|
|
label: t("Data value"),
|
|
value: dataLevel as any,
|
|
onChange: (newData: string | number | undefined) => this.changeStop(idx, { zoom: zoomLevel, value: newData as number }, value)
|
|
};
|
|
|
|
let dataInput;
|
|
if(this.props.value?.type === "categorical") {
|
|
dataInput = <InputString {...dataProps} />;
|
|
}
|
|
else {
|
|
dataInput = <InputNumber {...dataProps} />;
|
|
}
|
|
|
|
let zoomInput = null;
|
|
if(zoomLevel !== undefined) {
|
|
zoomInput = <div>
|
|
<InputNumber
|
|
aria-label="Zoom"
|
|
value={zoomLevel}
|
|
onChange={newZoom => this.changeStop(idx, {zoom: newZoom, value: dataLevel}, value)}
|
|
min={0}
|
|
max={22}
|
|
/>
|
|
</div>;
|
|
}
|
|
|
|
return <tr key={key}>
|
|
<td>
|
|
{zoomInput}
|
|
</td>
|
|
<td>
|
|
{dataInput}
|
|
</td>
|
|
<td>
|
|
<InputSpec
|
|
aria-label={t("Output value")}
|
|
fieldName={this.props.fieldName}
|
|
fieldSpec={this.props.fieldSpec}
|
|
value={value}
|
|
onChange={(_, newValue) => this.changeStop(idx, {zoom: zoomLevel, value: dataLevel}, newValue as number)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
{deleteStopBtn}
|
|
</td>
|
|
</tr>;
|
|
});
|
|
}
|
|
|
|
return <div className="maputnik-data-spec-block">
|
|
<fieldset className="maputnik-data-spec-property">
|
|
<legend>{labelFromFieldName(this.props.fieldName)}</legend>
|
|
<div className="maputnik-data-fieldset-inner">
|
|
<Block
|
|
label={t("Function")}
|
|
key="function"
|
|
data-wd-key="function-type"
|
|
>
|
|
<div className="maputnik-data-spec-property-input">
|
|
<InputSelect
|
|
value={this.props.value!.type}
|
|
onChange={(propVal: string) => this.changeDataType(propVal)}
|
|
title={t("Select a type of data scale (default is 'categorical').")}
|
|
options={this.getDataFunctionTypes(this.props.fieldSpec)}
|
|
/>
|
|
</div>
|
|
</Block>
|
|
{this.props.value?.type !== "identity" &&
|
|
<Block
|
|
label={t("Base")}
|
|
key="base"
|
|
data-wd-key="function-base"
|
|
>
|
|
<div className="maputnik-data-spec-property-input">
|
|
<InputSpec
|
|
fieldName={"base"}
|
|
fieldSpec={latest.function.base as typeof latest.function.base & { type: "number" }}
|
|
value={this.props.value?.base}
|
|
onChange={(_, newValue) => this.changeBase(newValue as number)}
|
|
/>
|
|
</div>
|
|
</Block>
|
|
}
|
|
<Block
|
|
label={"Property"}
|
|
key="property"
|
|
data-wd-key="function-property"
|
|
>
|
|
<div className="maputnik-data-spec-property-input">
|
|
<InputString
|
|
value={this.props.value?.property}
|
|
title={t("Input a data property to base styles off of.")}
|
|
onChange={propVal => this.changeDataProperty("property", propVal)}
|
|
/>
|
|
</div>
|
|
</Block>
|
|
{dataFields &&
|
|
<Block
|
|
label={t("Default")}
|
|
key="default"
|
|
data-wd-key="function-default"
|
|
>
|
|
<InputSpec
|
|
fieldName={this.props.fieldName}
|
|
fieldSpec={this.props.fieldSpec}
|
|
value={this.props.value?.default}
|
|
onChange={(_, propVal) => this.changeDataProperty("default", propVal)}
|
|
/>
|
|
</Block>
|
|
}
|
|
{dataFields &&
|
|
<div className="maputnik-function-stop">
|
|
<table className="maputnik-function-stop-table">
|
|
<caption>{t("Stops")}</caption>
|
|
<thead>
|
|
<tr>
|
|
<th>{t("Zoom")}</th>
|
|
<th>{t("Input value")}</th>
|
|
<th rowSpan={2}>{t("Output value")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{dataFields}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
}
|
|
<div className="maputnik-toolbox">
|
|
{dataFields &&
|
|
<InputButton
|
|
className="maputnik-add-stop"
|
|
onClick={this.props.onAddStop?.bind(this)}
|
|
>
|
|
<PiListPlusBold style={{ verticalAlign: "text-bottom" }} />
|
|
{t("Add stop")}
|
|
</InputButton>
|
|
}
|
|
<InputButton
|
|
className="maputnik-add-stop"
|
|
data-wd-key="convert-to-expression"
|
|
onClick={this.props.onExpressionClick?.bind(this)}
|
|
>
|
|
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
|
{t("Convert to expression")}
|
|
</InputButton>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
</div>;
|
|
}
|
|
}
|
|
|
|
export const DataProperty = withTranslation()(DataPropertyInternal);
|