mirror of
https://github.com/maputnik/editor.git
synced 2026-09-04 19:37: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>
271 lines
8.1 KiB
TypeScript
271 lines
8.1 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 { type WithTranslation, withTranslation } from "react-i18next";
|
|
|
|
import { InputButton } from "./InputButton";
|
|
import { InputSpec } from "./InputSpec";
|
|
import { InputNumber } from "./InputNumber";
|
|
import { InputSelect } from "./InputSelect";
|
|
import { Block } from "./Block";
|
|
|
|
import { DeleteStopButton } from "./DeleteStopButton";
|
|
import { labelFromFieldName } from "../libs/label-from-field-name";
|
|
|
|
import { generateUniqueId as docUid } from "../libs/document-uid";
|
|
import { sortNumerically } from "../libs/sort-numerically";
|
|
import { type MappedLayerErrors } from "../libs/definitions";
|
|
|
|
|
|
/**
|
|
* We cache a reference for each stop by its index.
|
|
*
|
|
* When the stops are reordered the references are also updated (see this.orderStops) this allows React to use the same key for the element and keep keyboard focus.
|
|
*/
|
|
function setStopRefs(props: ZoomPropertyInternalProps, state: ZoomPropertyState) {
|
|
// This is initialised below only if required to improved performance.
|
|
let newRefs: {[key: number]: string} = {};
|
|
|
|
if(props.value && (props.value as ZoomWithStops).stops) {
|
|
(props.value as ZoomWithStops).stops.forEach((_val, idx: number) => {
|
|
if(Object.prototype.hasOwnProperty.call(!state.refs, idx)) {
|
|
if(!newRefs) {
|
|
newRefs = {...state};
|
|
}
|
|
newRefs[idx] = docUid("stop-");
|
|
} else {
|
|
newRefs[idx] = state.refs[idx];
|
|
}
|
|
});
|
|
}
|
|
return newRefs;
|
|
}
|
|
|
|
type ZoomWithStops = {
|
|
stops: [number | undefined, number][]
|
|
base?: number
|
|
};
|
|
|
|
|
|
type ZoomPropertyInternalProps = {
|
|
onChange?(...args: unknown[]): unknown
|
|
onChangeToDataFunction?(...args: unknown[]): unknown
|
|
onDeleteStop?(...args: unknown[]): unknown
|
|
onAddStop?(...args: unknown[]): unknown
|
|
onExpressionClick?(...args: unknown[]): unknown
|
|
fieldType?: string
|
|
fieldName: string
|
|
fieldSpec?: {
|
|
"property-type"?: string
|
|
"function-type"?: string
|
|
}
|
|
errors?: MappedLayerErrors
|
|
value?: ZoomWithStops
|
|
} & WithTranslation;
|
|
|
|
type ZoomPropertyState = {
|
|
refs: {[key: number]: string}
|
|
};
|
|
|
|
class ZoomPropertyInternal extends React.Component<ZoomPropertyInternalProps, ZoomPropertyState> {
|
|
static defaultProps = {
|
|
errors: {},
|
|
};
|
|
|
|
state = {
|
|
refs: {} as {[key: number]: string}
|
|
};
|
|
|
|
componentDidMount() {
|
|
const newRefs = setStopRefs(this.props, this.state);
|
|
|
|
if(newRefs) {
|
|
this.setState({
|
|
refs: newRefs
|
|
});
|
|
}
|
|
}
|
|
|
|
static getDerivedStateFromProps(props: Readonly<ZoomPropertyInternalProps>, state: ZoomPropertyState) {
|
|
const newRefs = setStopRefs(props, state);
|
|
if(newRefs) {
|
|
return {
|
|
refs: newRefs
|
|
};
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Order the stops altering the refs to reflect their new position.
|
|
orderStopsByZoom(stops: ZoomWithStops["stops"]) {
|
|
const mappedWithRef = stops
|
|
.map((stop, idx) => {
|
|
return {
|
|
ref: this.state.refs[idx],
|
|
data: stop
|
|
};
|
|
})
|
|
// Sort by zoom
|
|
.sort((a, b) => sortNumerically(a.data[0]!, b.data[0]!));
|
|
|
|
// Fetch the new position of the stops
|
|
const newRefs: {[key:number]: string} = {};
|
|
mappedWithRef
|
|
.forEach((stop, idx) =>{
|
|
newRefs[idx] = stop.ref;
|
|
});
|
|
|
|
this.setState({
|
|
refs: newRefs
|
|
});
|
|
|
|
return mappedWithRef.map((item) => item.data);
|
|
}
|
|
|
|
changeZoomStop(changeIdx: number, stopData: number | undefined, value: number) {
|
|
const stops = (this.props.value as ZoomWithStops).stops.slice(0);
|
|
stops[changeIdx] = [stopData, value];
|
|
|
|
const orderedStops = this.orderStopsByZoom(stops);
|
|
|
|
const changedValue = {
|
|
...this.props.value as ZoomWithStops,
|
|
stops: orderedStops
|
|
};
|
|
this.props.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 = (type: string) => {
|
|
if (type !== "interpolate" && this.props.onChangeToDataFunction) {
|
|
this.props.onChangeToDataFunction(type);
|
|
}
|
|
};
|
|
|
|
render() {
|
|
const t = this.props.t;
|
|
const zoomFields = this.props.value?.stops.map((stop, idx) => {
|
|
const zoomLevel = stop[0];
|
|
const value = stop[1];
|
|
const deleteStopBtn = <DeleteStopButton onClick={this.props.onDeleteStop?.bind(this, idx)} />;
|
|
return <tr
|
|
key={`${stop[0]}-${stop[1]}`}
|
|
>
|
|
<td>
|
|
<InputNumber
|
|
aria-label={t("Zoom")}
|
|
value={zoomLevel}
|
|
onChange={changedStop => this.changeZoomStop(idx, changedStop, value)}
|
|
min={0}
|
|
max={22}
|
|
/>
|
|
</td>
|
|
<td>
|
|
<InputSpec
|
|
aria-label={t("Output value")}
|
|
fieldName={this.props.fieldName}
|
|
fieldSpec={this.props.fieldSpec as any}
|
|
value={value}
|
|
onChange={(_, newValue) => this.changeZoomStop(idx, zoomLevel, newValue as number)}
|
|
/>
|
|
</td>
|
|
<td>
|
|
{deleteStopBtn}
|
|
</td>
|
|
</tr>;
|
|
});
|
|
|
|
// return <div className="maputnik-zoom-spec-property">
|
|
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")}
|
|
data-wd-key="function-type"
|
|
>
|
|
<div className="maputnik-data-spec-property-input">
|
|
<InputSelect
|
|
value={"interpolate"}
|
|
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>
|
|
<Block
|
|
label={t("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 | undefined)}
|
|
/>
|
|
</div>
|
|
</Block>
|
|
<div className="maputnik-function-stop">
|
|
<table className="maputnik-function-stop-table maputnik-function-stop-table--zoom">
|
|
<caption>{t("Stops")}</caption>
|
|
<thead>
|
|
<tr>
|
|
<th>{t("Zoom")}</th>
|
|
<th rowSpan={2}>{t("Output value")}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{zoomFields}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
<div className="maputnik-toolbox">
|
|
<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>;
|
|
}
|
|
|
|
getDataFunctionTypes(fieldSpec: {
|
|
"property-type"?: string
|
|
"function-type"?: string
|
|
}) {
|
|
if (fieldSpec["property-type"] === "data-driven") {
|
|
return ["interpolate", "categorical", "interval", "exponential", "identity"];
|
|
}
|
|
else {
|
|
return ["interpolate"];
|
|
}
|
|
}
|
|
}
|
|
|
|
export const ZoomProperty = withTranslation()(ZoomPropertyInternal);
|