Color relief support and hillshading improvements. (#1371)

## Launch Checklist

This adds support for `relief-color` property so that it will create an
elevation expression when the button is pressed.
It also adds support for `colorArray` and `numberArray` types so that
the user would be able to add the relevant information.

Before:
<img width="403" height="324" alt="image"
src="https://github.com/user-attachments/assets/250abd81-6176-4711-a1ee-d33d443932d7"
/>

<img width="403" height="324" alt="image"
src="https://github.com/user-attachments/assets/6a1bb268-66db-42a1-97fc-33e5a40863b6"
/>


After:
<img width="403" height="324" alt="image"
src="https://github.com/user-attachments/assets/8ebaa1ea-4ef9-4aed-abcd-3c8b0057ea76"
/>

<img width="403" height="324" alt="image"
src="https://github.com/user-attachments/assets/e0728c92-85f9-4b86-8635-8877cf257b2f"
/>


 - [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>
This commit is contained in:
Harel M
2025-09-11 20:43:20 +03:00
committed by GitHub
parent d81316435b
commit 42e1273241
20 changed files with 258 additions and 53 deletions

View File

@@ -3,7 +3,6 @@ import classnames from 'classnames'
import FieldDocLabel from './FieldDocLabel'
import Doc from './Doc'
type BlockProps = PropsWithChildren & {
"data-wd-key"?: string
label?: string

View File

@@ -90,6 +90,18 @@ function getDataType(value: any, fieldSpec={} as any) {
else if (fieldSpec.type === "array" && isArrayOfPrimatives(value)) {
return "value";
}
else if (fieldSpec.type === "numberArray" && isArrayOfPrimatives(value)) {
return "value";
}
else if (fieldSpec.type === "colorArray") {
return "value";
}
else if (fieldSpec.type === "padding") {
return "value";
}
else if (fieldSpec.type === "variableAnchorOffsetCollection") {
return "value";
}
else if (isZoomField(value)) {
return "zoom_function";
}
@@ -293,6 +305,20 @@ const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
props.onChange(props.fieldName, dataFunc);
};
const makeElevationFunction = () => {
const expression = [
"interpolate",
["linear"],
["elevation"],
0,
"black",
2000,
"white"
];
props.onChange(props.fieldName, expression);
};
const onMarkEditing = () => {
setIsEditing(true);
};
@@ -364,6 +390,7 @@ const FieldFunction: React.FC<FieldFunctionProps> = (props) => {
onZoomClick={makeZoomFunction}
onDataClick={makeDataFunction}
onExpressionClick={makeExpression}
onElevationClick={makeElevationFunction}
/>
);
}

View File

@@ -1,37 +1,45 @@
import Block from './Block'
import InputSpec, { InputSpecProps } from './InputSpec'
import InputSpec, { FieldSpecType, InputSpecProps } from './InputSpec'
import Fieldset from './Fieldset'
const typeMap = {
color: () => Block,
enum: ({fieldSpec}: any) => (Object.keys(fieldSpec.values).length <= 3 ? Fieldset : Block),
boolean: () => Block,
array: () => Fieldset,
resolvedImage: () => Block,
number: () => Block,
string: () => Block,
formatted: () => Block,
padding: () => Block,
};
function getElementFromType(fieldSpec: { type?: FieldSpecType, values?: unknown[] }): typeof Fieldset | typeof Block {
switch(fieldSpec.type) {
case 'color':
return Block;
case 'enum':
return (Object.keys(fieldSpec.values!).length <= 3 ? Fieldset : Block)
case 'boolean':
return Block;
case 'array':
return Fieldset;
case 'resolvedImage':
return Block;
case 'number':
return Block;
case 'string':
return Block;
case 'formatted':
return Block;
case 'padding':
return Block;
case 'numberArray':
return Fieldset;
case 'colorArray':
return Fieldset;
case 'variableAnchorOffsetCollection':
return Fieldset;
default:
console.warn("No such type for: " + fieldSpec.type);
return Block;
}
}
export type FieldSpecProps = InputSpecProps & {
name?: string
};
const FieldSpec: React.FC<FieldSpecProps> = (props) => {
const fieldType = props.fieldSpec?.type;
const typeBlockFn = typeMap[fieldType!];
let TypeBlock;
if (typeBlockFn) {
TypeBlock = typeBlockFn(props);
}
else {
console.warn("No such type for '%s'", fieldType);
TypeBlock = Block;
}
const TypeBlock = getElementFromType(props.fieldSpec!);
return (
<TypeBlock label={props.label} action={props.action} fieldSpec={props.fieldSpec}>

View File

@@ -1,8 +1,8 @@
import React from 'react'
import type {CSSProperties} from 'react'
import { BsDiamond, BsDiamondFill, BsFonts } from 'react-icons/bs'
import { MdOutlineCircle, MdPriorityHigh } from 'react-icons/md'
import { BsDiamond, BsDiamondFill, BsFonts, BsSun } from 'react-icons/bs'
import { MdBubbleChart, MdOutlineCircle, MdPriorityHigh } from 'react-icons/md'
import { IoAnalyticsOutline } from 'react-icons/io5'
type IconLayerProps = {
@@ -16,7 +16,8 @@ const IconLayer: React.FC<IconLayerProps> = (props) => {
switch(props.type) {
case 'fill-extrusion': return <BsDiamondFill {...iconProps} />
case 'raster': return <BsDiamond {...iconProps} />
case 'hillshade': return <BsDiamond {...iconProps} />
case 'hillshade': return <BsSun {...iconProps} />
case 'color-relief': return <MdBubbleChart {...iconProps} />
case 'heatmap': return <BsDiamond {...iconProps} />
case 'fill': return <BsDiamond {...iconProps} />
case 'background': return <BsDiamondFill {...iconProps} />

View File

@@ -9,11 +9,12 @@ import InputButton from './InputButton'
import FieldDocLabel from './FieldDocLabel'
import InputEnum from './InputEnum'
import InputUrl from './InputUrl'
import InputColor from './InputColor';
export type InputDynamicArrayProps = {
value?: (string | number | undefined)[]
type?: 'url' | 'number' | 'enum' | 'string'
type?: 'url' | 'number' | 'enum' | 'string' | 'color'
default?: (string | number | undefined)[]
onChange?(values: (string | number | undefined)[] | undefined): unknown
style?: object
@@ -49,6 +50,8 @@ class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInterna
const {fieldSpec} = this.props;
const defaultValue = Object.keys(fieldSpec!.values)[0];
values.push(defaultValue);
} else if (this.props.type === 'color') {
values.push("#000000");
} else {
values.push("")
}
@@ -95,6 +98,13 @@ class InputDynamicArrayInternal extends React.Component<InputDynamicArrayInterna
aria-label={this.props['aria-label'] || this.props.label}
/>
}
else if (this.props.type === 'color') {
input = <InputColor
value={v as string}
onChange={this.changeValue.bind(this, i)}
aria-label={this.props['aria-label'] || this.props.label}
/>
}
else {
input = <InputString
value={v as string}

View File

@@ -10,10 +10,11 @@ import 'codemirror/lib/codemirror.css'
import 'codemirror/addon/lint/lint.css'
import stringifyPretty from 'json-stringify-pretty-compact'
import '../libs/codemirror-mgl';
import type { LayerSpecification } from 'maplibre-gl';
export type InputJsonProps = {
layer: any
layer: LayerSpecification
maxHeight?: number
onChange?(...args: unknown[]): unknown
lineNumbers?: boolean

View File

@@ -13,12 +13,14 @@ import capitalize from 'lodash.capitalize'
const iconProperties = ['background-pattern', 'fill-pattern', 'line-pattern', 'fill-extrusion-pattern', 'icon-image']
export type FieldSpecType = 'number' | 'enum' | 'resolvedImage' | 'formatted' | 'string' | 'color' | 'boolean' | 'array' | 'numberArray' | 'padding' | 'colorArray' | 'variableAnchorOffsetCollection';
export type InputSpecProps = {
onChange?(fieldName: string | undefined, value: number | undefined | (string | number | undefined)[]): unknown
fieldName?: string
fieldSpec?: {
default?: unknown
type?: 'number' | 'enum' | 'resolvedImage' | 'formatted' | 'string' | 'color' | 'boolean' | 'array'
type?: FieldSpecType
minimum?: number
maximum?: number
values?: unknown[]
@@ -114,7 +116,33 @@ export default class InputSpec extends React.Component<InputSpecProps> {
/>
}
}
default: return null
case 'numberArray': return (
<InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec}
type="number"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/>
)
case 'colorArray': return (
<InputDynamicArray
{...commonProps as InputDynamicArrayProps}
fieldSpec={this.props.fieldSpec}
type="color"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
/>
)
case 'padding': return (
<InputArray
{...commonProps as InputArrayProps}
type="number"
value={(Array.isArray(this.props.value) ? this.props.value : [this.props.value]) as (string | number | undefined)[]}
length={4}
/>
)
default:
console.warn(`No proper field input for ${this.props.fieldName} type: ${this.props.fieldSpec?.type}`);
return null
}
}

View File

@@ -118,7 +118,7 @@ type LayerEditorInternalProps = {
layer: LayerSpecification
sources: {[key: string]: SourceSpecification & {layers: string[]}}
vectorLayers: {[key: string]: any}
spec: object
spec: any
onLayerChanged(...args: unknown[]): unknown
onLayerIdChange(...args: unknown[]): unknown
onMoveLayer: OnMoveLayerCallback
@@ -367,6 +367,7 @@ class LayerEditorInternal extends React.Component<LayerEditorInternalProps, Laye
<section className="maputnik-layer-editor"
role="main"
aria-label={t("Layer editor")}
data-wd-key="layer-editor"
>
<header>
<div className="layer-header">

View File

@@ -26,13 +26,9 @@ function getFieldSpec(spec: any, layerType: LayerSpecification["type"], fieldNam
return fieldSpec
}
function getGroupName(spec: any, layerType: LayerSpecification["type"], fieldName: string) {
const paint = spec['paint_' + layerType] || {}
if (fieldName in paint) {
return 'paint'
} else {
return 'layout'
}
function getGroupName(spec: any, layerType: LayerSpecification["type"], fieldName: string): 'paint' | 'layout' {
const paint = spec['paint_' + layerType] || {}
return (fieldName in paint) ? 'paint' : 'layout';
}
type PropertyGroupProps = {

View File

@@ -7,18 +7,18 @@ import { WithTranslation, withTranslation } from 'react-i18next';
type FunctionInputButtonsInternalProps = {
fieldSpec?: any
onZoomClick?(...args: unknown[]): unknown
onDataClick?(...args: unknown[]): unknown
onExpressionClick?(...args: unknown[]): unknown
onZoomClick?(): void
onDataClick?(): void
onExpressionClick?(): void
onElevationClick?(): void
} & WithTranslation;
class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsInternalProps> {
render() {
const t = this.props.t;
let makeZoomInputButton, makeDataInputButton, expressionInputButton;
if (this.props.fieldSpec.expression?.parameters.includes('zoom')) {
expressionInputButton = (
const expressionInputButton = (
<InputButton
className="maputnik-make-zoom-function"
onClick={this.props.onExpressionClick}
@@ -30,7 +30,7 @@ class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsI
</InputButton>
);
makeZoomInputButton = <InputButton
const makeZoomInputButton = <InputButton
className="maputnik-make-zoom-function"
onClick={this.props.onZoomClick}
title={t("Convert property into a zoom function")}
@@ -38,6 +38,7 @@ class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsI
<MdFunctions />
</InputButton>
let makeDataInputButton;
if (this.props.fieldSpec['property-type'] === 'data-driven') {
makeDataInputButton = <InputButton
className="maputnik-make-data-function"
@@ -52,9 +53,18 @@ class FunctionInputButtonsInternal extends React.Component<FunctionInputButtonsI
{makeDataInputButton}
{makeZoomInputButton}
</div>
}
else {
return <div>{expressionInputButton}</div>
} else if (this.props.fieldSpec.expression?.parameters.includes('elevation')) {
const inputElevationButton = <InputButton
className="maputnik-make-elevation-function"
onClick={this.props.onElevationClick}
title={t("Convert property into a elevation function")}
data-wd-key='make-elevation-function'
>
<MdFunctions />
</InputButton>
return <div>{inputElevationButton}</div>
} else {
return <div></div>
}
}
}

View File

@@ -7,14 +7,15 @@ import labelFromFieldName from '../libs/label-from-field-name'
type SpecPropertyProps = FieldSpecProps & {
onZoomClick(...args: unknown[]): unknown
onDataClick(...args: unknown[]): unknown
fieldName?: string
fieldType?: string
fieldSpec?: any
value?: any
errors?: {[key: string]: {message: string}}
onExpressionClick?(...args: unknown[]): unknown
onZoomClick(): void
onDataClick(): void
onExpressionClick?(): void
onElevationClick?(): void
};
@@ -31,6 +32,7 @@ export default class SpecProperty extends React.Component<SpecPropertyProps> {
onZoomClick={this.props.onZoomClick}
onDataClick={this.props.onDataClick}
onExpressionClick={this.props.onExpressionClick}
onElevationClick={this.props.onElevationClick}
/>
const error = errors![fieldType+"."+fieldName as any] as any;