mirror of
https://github.com/maputnik/editor.git
synced 2025-12-24 23:20:00 +00:00
## Launch Checklist This PR adds back the error panel which was under the map for some reason. It also highlights problematic layers in the layers list (which already worked). It also highlights the field that has an error related to it. It fixes the error types throughout the code. Before: <img width="1141" height="665" alt="image" src="https://github.com/user-attachments/assets/c0593d6c-8f14-41b3-8a51-bc359446656d" /> After: <img width="1141" height="665" alt="image" src="https://github.com/user-attachments/assets/1ffeebb7-31ea-4ed5-97f4-fc5f907a6aea" /> - [x] Briefly describe the changes in this PR. - [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: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
385 lines
11 KiB
TypeScript
385 lines
11 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 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 initialsed 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"
|
|
>
|
|
<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"
|
|
>
|
|
<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"
|
|
>
|
|
<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"
|
|
>
|
|
<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"
|
|
onClick={this.props.onExpressionClick?.bind(this)}
|
|
>
|
|
<TbMathFunction style={{ verticalAlign: "text-bottom" }} />
|
|
{t("Convert to expression")}
|
|
</InputButton>
|
|
</div>
|
|
</div>
|
|
</fieldset>
|
|
</div>;
|
|
}
|
|
}
|
|
|
|
const DataProperty = withTranslation()(DataPropertyInternal);
|
|
export default DataProperty;
|