From 87ceddbe9d6a86e9fc974b84a7b0599ace8ff088 Mon Sep 17 00:00:00 2001 From: Greg Mooney Date: Tue, 24 Sep 2024 09:18:21 +0200 Subject: [PATCH] Vector symbology (#152) * Start adding vector symbology * Move stop row interface * Working pretty much * I think filters work * Yea it works * WIP * WIP * WIP * Big parse refactor * Rename simple symbol thing * Clean up * Save selected render type * WIP * State name change * Name change * Retain form state * CSS * Easy version * Add vector tile symbology * Remove comment * Update snapshots --------- Co-authored-by: martinRenou --- examples/earthquakes-no-filter.jGIS | 64 ---- examples/earthquakes.jGIS | 70 ---- examples/geojson.jGIS | 134 ++++++++ examples/geotiff-2.jGIS | 118 ------- examples/geotiff.jGIS | 70 ++-- examples/test.jGIS | 24 +- .../components/symbology/Graduated.tsx | 299 ++++++++++++++++++ .../components/symbology/SimpleSymbol.tsx | 243 ++++++++++++++ .../symbology/SingleBandPseudoColor.tsx | 49 ++- .../dialogs/components/symbology/StopRow.tsx | 55 ++-- .../components/symbology/VectorRendering.tsx | 106 +++++++ packages/base/src/dialogs/symbologyDialog.tsx | 20 +- .../formbuilder/objectform/vectorlayerform.ts | 5 +- packages/base/src/mainview/mainView.tsx | 147 ++++----- .../base/src/panelview/components/layers.tsx | 12 +- packages/base/src/tools.ts | 53 ++++ packages/base/style/symbologyDialog.css | 14 +- .../no-filter-linux.png | Bin 108949 -> 107020 bytes .../one-filter-linux.png | Bin 99168 -> 81063 bytes .../two-filter-linux.png | Bin 94874 -> 75574 bytes .../geoJSON-layer-linux.png | Bin 19161 -> 22762 bytes .../top-layer-hidden-linux.png | Bin 117516 -> 188656 bytes .../dark-Notebook-ipynb-cell-1-linux.png | Bin 567342 -> 261488 bytes .../dark-Notebook-ipynb-cell-2-linux.png | Bin 409831 -> 557164 bytes .../light-Notebook-ipynb-cell-1-linux.png | Bin 564308 -> 261386 bytes .../light-Notebook-ipynb-cell-2-linux.png | Bin 398627 -> 564111 bytes 26 files changed, 1042 insertions(+), 441 deletions(-) delete mode 100644 examples/earthquakes-no-filter.jGIS delete mode 100644 examples/earthquakes.jGIS create mode 100644 examples/geojson.jGIS delete mode 100644 examples/geotiff-2.jGIS create mode 100644 packages/base/src/dialogs/components/symbology/Graduated.tsx create mode 100644 packages/base/src/dialogs/components/symbology/SimpleSymbol.tsx create mode 100644 packages/base/src/dialogs/components/symbology/VectorRendering.tsx diff --git a/examples/earthquakes-no-filter.jGIS b/examples/earthquakes-no-filter.jGIS deleted file mode 100644 index a3cedb8e..00000000 --- a/examples/earthquakes-no-filter.jGIS +++ /dev/null @@ -1,64 +0,0 @@ -{ - "layerTree": [ - "f907e26c-c4c8-4c2d-ad62-813f63ed9de9", - "0336896f-f7ce-460f-8d11-07a589ff03d8" - ], - "layers": { - "0336896f-f7ce-460f-8d11-07a589ff03d8": { - "filters": { - "appliedFilters": [], - "logicalOp": "all" - }, - "name": "Custom GeoJSON Layer", - "parameters": { - "color": "#FF0000", - "opacity": 1.0, - "source": "d07cc573-51fb-4ae8-965b-a0082ace7f2b", - "type": "circle" - }, - "type": "VectorLayer", - "visible": true - }, - "f907e26c-c4c8-4c2d-ad62-813f63ed9de9": { - "name": "OpenStreetMap.Mapnik Layer", - "parameters": { - "source": "5fe556bc-9938-4217-8de6-fe25aef088c2" - }, - "type": "RasterLayer", - "visible": true - } - }, - "options": { - "bearing": 0.0, - "latitude": 38.612326230162665, - "longitude": -119.77357975468912, - "pitch": 0.0, - "projection": "EPSG:3857", - "zoom": 5.161762963106246 - }, - "sources": { - "5fe556bc-9938-4217-8de6-fe25aef088c2": { - "name": "OpenStreetMap.Mapnik", - "parameters": { - "attribution": "(C) OpenStreetMap contributors", - "maxZoom": 19.0, - "minZoom": 0.0, - "provider": "OpenStreetMap", - "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - "urlParameters": {} - }, - "type": "RasterSource" - }, - "d07cc573-51fb-4ae8-965b-a0082ace7f2b": { - "name": "Custom GeoJSON Layer Source", - "parameters": { - "path": "eq.json" - }, - "type": "GeoJSONSource" - } - }, - "terrain": { - "exaggeration": 0.0, - "source": "" - } -} diff --git a/examples/earthquakes.jGIS b/examples/earthquakes.jGIS deleted file mode 100644 index 1ed6929b..00000000 --- a/examples/earthquakes.jGIS +++ /dev/null @@ -1,70 +0,0 @@ -{ - "layerTree": [ - "f907e26c-c4c8-4c2d-ad62-813f63ed9de9", - "0336896f-f7ce-460f-8d11-07a589ff03d8" - ], - "layers": { - "0336896f-f7ce-460f-8d11-07a589ff03d8": { - "filters": { - "appliedFilters": [ - { - "feature": "mag", - "operator": ">", - "value": 6.5 - } - ], - "logicalOp": "all" - }, - "name": "Custom GeoJSON Layer", - "parameters": { - "color": "#000000", - "opacity": 1.0, - "source": "d07cc573-51fb-4ae8-965b-a0082ace7f2b", - "type": "circle" - }, - "type": "VectorLayer", - "visible": true - }, - "f907e26c-c4c8-4c2d-ad62-813f63ed9de9": { - "name": "OpenStreetMap.Mapnik Layer", - "parameters": { - "source": "5fe556bc-9938-4217-8de6-fe25aef088c2" - }, - "type": "RasterLayer", - "visible": true - } - }, - "options": { - "bearing": 0.0, - "latitude": 25.012589761592906, - "longitude": -109.44110652310296, - "pitch": 0.0, - "projection": "EPSG:3857", - "zoom": 3.843213728870569 - }, - "sources": { - "5fe556bc-9938-4217-8de6-fe25aef088c2": { - "name": "OpenStreetMap.Mapnik", - "parameters": { - "attribution": "(C) OpenStreetMap contributors", - "maxZoom": 19.0, - "minZoom": 0.0, - "provider": "OpenStreetMap", - "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - "urlParameters": {} - }, - "type": "RasterSource" - }, - "d07cc573-51fb-4ae8-965b-a0082ace7f2b": { - "name": "Custom GeoJSON Layer Source", - "parameters": { - "path": "eq.json" - }, - "type": "GeoJSONSource" - } - }, - "terrain": { - "exaggeration": 0.0, - "source": "" - } -} diff --git a/examples/geojson.jGIS b/examples/geojson.jGIS new file mode 100644 index 00000000..e44b4d09 --- /dev/null +++ b/examples/geojson.jGIS @@ -0,0 +1,134 @@ +{ + "layerTree": [ + "0959c04f-a841-4fa2-8b44-d262e89e4c9a", + "6dc9af9d-206d-42b5-9889-09758e9934b9", + "c844b62f-8175-43aa-952d-305f170778be" + ], + "layers": { + "0959c04f-a841-4fa2-8b44-d262e89e4c9a": { + "name": "OpenStreetMap.Mapnik Layer", + "parameters": { + "source": "a7ed9785-8797-4d6d-a6a9-062ce78ba7ba" + }, + "type": "RasterLayer", + "visible": true + }, + "6dc9af9d-206d-42b5-9889-09758e9934b9": { + "filters": { + "appliedFilters": [], + "logicalOp": "any" + }, + "name": "earthquakes", + "parameters": { + "color": { + "circle-fill-color": [ + "interpolate", + [ + "linear" + ], + [ + "get", + "mag" + ], + 2.0, + [ + 143.0, + 240.0, + 164.0, + 1.0 + ], + 4.0, + [ + 255.0, + 190.0, + 111.0, + 1.0 + ], + 6.0, + [ + 246.0, + 97.0, + 81.0, + 1.0 + ] + ], + "circle-radius": [ + "interpolate", + [ + "linear" + ], + [ + "get", + "mag" + ], + 2.0, + 5.0, + 4.0, + 10.0, + 6.0, + 15.0 + ], + "circle-stroke-color": "#986a44", + "circle-stroke-line-cap": "round", + "circle-stroke-line-join": "round", + "circle-stroke-width": 1.25 + }, + "opacity": 1.0, + "source": "4a74edbc-1939-40e3-a0ac-28b2e1d87846", + "type": "circle" + }, + "type": "VectorLayer", + "visible": true + }, + "c844b62f-8175-43aa-952d-305f170778be": { + "name": "france regions", + "parameters": { + "opacity": 1.0, + "source": "4aab05ec-5a57-454b-9ba5-31bcf272feda", + "type": "fill" + }, + "type": "VectorLayer", + "visible": true + } + }, + "options": { + "bearing": 0.0, + "latitude": 26.349956480215155, + "longitude": -98.39490230130018, + "pitch": 0.0, + "projection": "EPSG:3857", + "zoom": 3.702740502822486 + }, + "sources": { + "4a74edbc-1939-40e3-a0ac-28b2e1d87846": { + "name": "Custom GeoJSON Layer Source", + "parameters": { + "path": "eq.json" + }, + "type": "GeoJSONSource" + }, + "4aab05ec-5a57-454b-9ba5-31bcf272feda": { + "name": "Custom GeoJSON Layer Source", + "parameters": { + "path": "france_regions.json" + }, + "type": "GeoJSONSource" + }, + "a7ed9785-8797-4d6d-a6a9-062ce78ba7ba": { + "name": "OpenStreetMap.Mapnik", + "parameters": { + "attribution": "(C) OpenStreetMap contributors", + "maxZoom": 19.0, + "minZoom": 0.0, + "provider": "OpenStreetMap", + "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", + "urlParameters": {} + }, + "type": "RasterSource" + } + }, + "terrain": { + "exaggeration": 0.0, + "source": "" + } +} diff --git a/examples/geotiff-2.jGIS b/examples/geotiff-2.jGIS deleted file mode 100644 index 917b81a6..00000000 --- a/examples/geotiff-2.jGIS +++ /dev/null @@ -1,118 +0,0 @@ -{ - "layerTree": [ - "6dd80360-2675-40cf-aaee-c7101ef1779a", - "f75fd646-bc7d-478b-b65b-de34155b8efa" - ], - "layers": { - "6dd80360-2675-40cf-aaee-c7101ef1779a": { - "name": "OpenStreetMap.Mapnik Layer", - "parameters": { - "source": "a6a98ac4-0d1c-4a3d-af71-88a91f28ccb9" - }, - "type": "RasterLayer", - "visible": true - }, - "f75fd646-bc7d-478b-b65b-de34155b8efa": { - "name": "Custom GeoTiff Layer", - "parameters": { - "color": [ - "interpolate", - [ - "linear" - ], - [ - "band", - 1.0 - ], - 0.0, - [ - 0.0, - 0.0, - 0.0, - 0.0 - ], - 0.1, - [ - 246.0, - 97.0, - 81.0, - 1.0 - ], - 0.25, - [ - 248.0, - 228.0, - 92.0, - 1.0 - ], - 0.5, - [ - 255.0, - 190.0, - 111.0, - 1.0 - ], - 0.75, - [ - 143.0, - 240.0, - 164.0, - 1.0 - ], - 1.0, - [ - 153.0, - 193.0, - 241.0, - 1.0 - ] - ], - "opacity": 1.0, - "source": "8b1d4258-5d46-48da-b466-496d376b593d" - }, - "type": "WebGlLayer", - "visible": true - } - }, - "options": { - "bearing": 0.0, - "latitude": 0.0, - "longitude": -88.43952200927704, - "pitch": 0.0, - "projection": "EPSG:3857", - "zoom": 2.2118882945460037 - }, - "sources": { - "8b1d4258-5d46-48da-b466-496d376b593d": { - "name": "Custom GeoTiff Source", - "parameters": { - "normalize": true, - "urls": [ - { - "max": 3000.0, - "min": 1000.0, - "url": "https://s2downloads.eox.at/demo/EOxCloudless/2020/rgbnir/s2cloudless2020-16bits_sinlge-file_z0-4.tif" - } - ], - "wrapX": true - }, - "type": "GeoTiffSource" - }, - "a6a98ac4-0d1c-4a3d-af71-88a91f28ccb9": { - "name": "OpenStreetMap.Mapnik", - "parameters": { - "attribution": "(C) OpenStreetMap contributors", - "maxZoom": 19.0, - "minZoom": 0.0, - "provider": "OpenStreetMap", - "url": "https://tile.openstreetmap.org/{z}/{x}/{y}.png", - "urlParameters": {} - }, - "type": "RasterSource" - } - }, - "terrain": { - "exaggeration": 0.0, - "source": "" - } -} diff --git a/examples/geotiff.jGIS b/examples/geotiff.jGIS index 32f24c53..917b81a6 100644 --- a/examples/geotiff.jGIS +++ b/examples/geotiff.jGIS @@ -1,18 +1,18 @@ { "layerTree": [ - "1b53b998-e6d5-461d-af06-427a0874421e", - "ed0e4142-38fb-4bae-ae3c-c674eccf4bf3" + "6dd80360-2675-40cf-aaee-c7101ef1779a", + "f75fd646-bc7d-478b-b65b-de34155b8efa" ], "layers": { - "1b53b998-e6d5-461d-af06-427a0874421e": { + "6dd80360-2675-40cf-aaee-c7101ef1779a": { "name": "OpenStreetMap.Mapnik Layer", "parameters": { - "source": "06ed2dc2-7d40-4fe3-adc0-325708e65103" + "source": "a6a98ac4-0d1c-4a3d-af71-88a91f28ccb9" }, "type": "RasterLayer", "visible": true }, - "ed0e4142-38fb-4bae-ae3c-c674eccf4bf3": { + "f75fd646-bc7d-478b-b65b-de34155b8efa": { "name": "Custom GeoTiff Layer", "parameters": { "color": [ @@ -31,28 +31,28 @@ 0.0, 0.0 ], - 0.0, + 0.1, [ 246.0, 97.0, 81.0, 1.0 ], - 0.24849699398797595, - [ - 255.0, - 190.0, - 111.0, - 1.0 - ], - 0.49899799599198397, + 0.25, [ 248.0, 228.0, 92.0, 1.0 ], - 0.749498997995992, + 0.5, + [ + 255.0, + 190.0, + 111.0, + 1.0 + ], + 0.75, [ 143.0, 240.0, @@ -68,7 +68,7 @@ ] ], "opacity": 1.0, - "source": "a99a3094-ff80-49ca-8607-e2cf3c0c5bda" + "source": "8b1d4258-5d46-48da-b466-496d376b593d" }, "type": "WebGlLayer", "visible": true @@ -76,14 +76,29 @@ }, "options": { "bearing": 0.0, - "latitude": 48.12458152421155, - "longitude": 3.135663050009803, + "latitude": 0.0, + "longitude": -88.43952200927704, "pitch": 0.0, "projection": "EPSG:3857", - "zoom": 8.44324014058031 + "zoom": 2.2118882945460037 }, "sources": { - "06ed2dc2-7d40-4fe3-adc0-325708e65103": { + "8b1d4258-5d46-48da-b466-496d376b593d": { + "name": "Custom GeoTiff Source", + "parameters": { + "normalize": true, + "urls": [ + { + "max": 3000.0, + "min": 1000.0, + "url": "https://s2downloads.eox.at/demo/EOxCloudless/2020/rgbnir/s2cloudless2020-16bits_sinlge-file_z0-4.tif" + } + ], + "wrapX": true + }, + "type": "GeoTiffSource" + }, + "a6a98ac4-0d1c-4a3d-af71-88a91f28ccb9": { "name": "OpenStreetMap.Mapnik", "parameters": { "attribution": "(C) OpenStreetMap contributors", @@ -94,21 +109,6 @@ "urlParameters": {} }, "type": "RasterSource" - }, - "a99a3094-ff80-49ca-8607-e2cf3c0c5bda": { - "name": "Custom GeoTiff Source", - "parameters": { - "normalize": true, - "urls": [ - { - "max": 10000.0, - "min": 20.0, - "url": "https://sentinel-cogs.s3.us-west-2.amazonaws.com/sentinel-s2-l2a-cogs/21/H/UB/2021/9/S2B_21HUB_20210915_0_L2A/B08.tif" - } - ], - "wrapX": false - }, - "type": "GeoTiffSource" } }, "terrain": { diff --git a/examples/test.jGIS b/examples/test.jGIS index 7e02cd4a..d7644e58 100644 --- a/examples/test.jGIS +++ b/examples/test.jGIS @@ -25,21 +25,21 @@ }, "57ef55ef-facb-48a2-ae1d-c9c824be3e8a": { "filters": { - "appliedFilters": [ - { - "feature": "code", - "operator": "==", - "value": "11" - } - ], + "appliedFilters": [], "logicalOp": "all" }, "name": "Regions France", "parameters": { - "color": "#e66100", + "color": { + "fill-color": "#99c1f1", + "stroke-color": "#3d3846", + "stroke-line-cap": "round", + "stroke-line-join": "round", + "stroke-width": 1.25 + }, "opacity": 0.6, "source": "7d082e75-69d5-447a-82d8-b05cca5945ba", - "type": "line" + "type": "fill" }, "type": "VectorLayer", "visible": true @@ -55,11 +55,11 @@ }, "options": { "bearing": 0.0, - "latitude": 45.40509940459734, - "longitude": 1.2354106422355888, + "latitude": 48.056463211980315, + "longitude": 1.2232823791613991, "pitch": 0.0, "projection": "EPSG:3857", - "zoom": 5.044381209731545 + "zoom": 6.033840529381983 }, "sources": { "5fd42e3b-4681-4607-b15d-65c3a3e89b32": { diff --git a/packages/base/src/dialogs/components/symbology/Graduated.tsx b/packages/base/src/dialogs/components/symbology/Graduated.tsx new file mode 100644 index 00000000..e59d5bc7 --- /dev/null +++ b/packages/base/src/dialogs/components/symbology/Graduated.tsx @@ -0,0 +1,299 @@ +import { GeoJSONFeature1 } from '@jupytergis/schema'; +import { Button } from '@jupyterlab/ui-components'; +import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; +import { ExpressionValue } from 'ol/expr/expression'; +import React, { useEffect, useRef, useState } from 'react'; +import { IStopRow, ISymbologyDialogProps } from '../../symbologyDialog'; +import StopRow from './StopRow'; + +const Graduated = ({ + context, + state, + okSignalPromise, + cancel, + layerId +}: ISymbologyDialogProps) => { + const selectedValueRef = useRef(); + const selectedMethodRef = useRef(); + const stopRowsRef = useRef(); + const layerStateRef = useRef(); + + const [selectedValue, setSelectedValue] = useState(''); + const [featureProperties, setFeatureProperties] = useState({}); + const [selectedMethod, setSelectedMethod] = useState('color'); + const [stopRows, setStopRows] = useState([]); + const [methodOptions, setMethodOptions] = useState(['color']); + const [layerState, setLayerState] = useState< + ReadonlyPartialJSONObject | undefined + >(); + + if (!layerId) { + return; + } + const layer = context.model.getLayer(layerId); + if (!layer?.parameters) { + return; + } + + useEffect(() => { + const getProperties = async () => { + if (!layerId) { + return; + } + const model = context.model; + const layer = model.getLayer(layerId); + const source = model.getSource(layer?.parameters?.source); + + if (!source) { + return; + } + + const data = await model.readGeoJSON(source.parameters?.path); + const featureProps: any = {}; + + data?.features.forEach((feature: GeoJSONFeature1) => { + feature.properties && + Object.entries(feature.properties).forEach(([key, value]) => { + if (!(key in featureProps)) { + featureProps[key] = new Set(); + } + + featureProps[key].add(value); + }); + + setFeatureProperties(featureProps); + }); + }; + + getProperties(); + buildColorInfo(); + + okSignalPromise.promise.then(okSignal => { + okSignal.connect(handleOk, this); + }); + + return () => { + okSignalPromise.promise.then(okSignal => { + okSignal.disconnect(handleOk, this); + }); + }; + }, []); + + useEffect(() => { + selectedValueRef.current = selectedValue; + selectedMethodRef.current = selectedMethod; + stopRowsRef.current = stopRows; + layerStateRef.current = layerState; + }, [selectedValue, selectedMethod, stopRows, layerState]); + + useEffect(() => { + populateOptions(); + }, [featureProperties]); + + const buildColorInfo = () => { + // This it to parse a color object on the layer + if (!layer.parameters?.color) { + return; + } + + const color = layer.parameters.color; + + // If color is a string we don't need to parse + if (typeof color === 'string') { + return; + } + + const prefix = layer.parameters.type === 'circle' ? 'circle-' : ''; + if (!color[`${prefix}fill-color`]) { + return; + } + + const valueColorPairs: IStopRow[] = []; + + // So if it's not a string then it's an array and we parse + // Color[0] is the operator used for the color expression + switch (color[`${prefix}fill-color`][0]) { + case 'interpolate': { + // First element is interpolate for linear selection + // Second element is type of interpolation (ie linear) + // Third is input value that stop values are compared with + // Fourth and on is value:color pairs + for (let i = 3; i < color[`${prefix}fill-color`].length; i += 2) { + const obj: IStopRow = { + stop: color[`${prefix}fill-color`][i], + output: color[`${prefix}fill-color`][i + 1] + }; + valueColorPairs.push(obj); + } + break; + } + } + + setStopRows(valueColorPairs); + }; + + const populateOptions = async () => { + // Set up method options + if (layer?.parameters?.type === 'circle') { + const options = ['color', 'radius']; + setMethodOptions(options); + } + + const layerState = await state.fetch(`jupytergis:${layerId}`); + + let value, method; + + if (layerState) { + value = (layerState as ReadonlyPartialJSONObject) + .graduatedValue as string; + method = (layerState as ReadonlyPartialJSONObject) + .graduatedMethod as string; + } + + setLayerState(layerState as ReadonlyPartialJSONObject); + setSelectedValue(value ? value : Object.keys(featureProperties)[0]); + setSelectedMethod(method ? method : 'color'); + }; + + const handleOk = () => { + if (!layer.parameters) { + return; + } + + state.save(`jupytergis:${layerId}`, { + ...layerStateRef.current, + renderType: 'Graduated', + graduatedValue: selectedValueRef.current, + graduatedMethod: selectedMethodRef.current + }); + + const colorExpr: ExpressionValue[] = []; + colorExpr.push('interpolate'); + colorExpr.push(['linear']); + colorExpr.push(['get', selectedValueRef.current]); + + stopRowsRef.current?.map(stop => { + colorExpr.push(stop.stop); + colorExpr.push(stop.output); + }); + + const newStyle = { ...layer.parameters.color }; + + if (selectedMethodRef.current === 'color') { + if (layer.parameters.type === 'fill') { + newStyle['fill-color'] = colorExpr; + } + + if (layer.parameters.type === 'line') { + newStyle['stroke-color'] = colorExpr; + } + + if (layer.parameters.type === 'circle') { + newStyle['circle-fill-color'] = colorExpr; + } + } + + if (selectedMethodRef.current === 'radius') { + if (layer.parameters.type === 'circle') { + newStyle['circle-radius'] = colorExpr; + } + } + + layer.parameters.color = newStyle; + + context.model.sharedModel.updateLayer(layerId, layer); + cancel(); + }; + + const addStopRow = () => { + setStopRows([ + { + stop: 0, + output: [0, 0, 0, 1] + }, + ...stopRows + ]); + }; + + const deleteStopRow = (index: number) => { + const newFilters = [...stopRows]; + newFilters.splice(index, 1); + + setStopRows(newFilters); + }; + + return ( +
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+ Value + Output Value +
+ {stopRows.map((stop, index) => ( + deleteStopRow(index)} + useNumber={selectedMethod === 'radius' ? true : false} + /> + ))} +
+
+ +
+
+ ); +}; + +export default Graduated; diff --git a/packages/base/src/dialogs/components/symbology/SimpleSymbol.tsx b/packages/base/src/dialogs/components/symbology/SimpleSymbol.tsx new file mode 100644 index 00000000..9327cc5d --- /dev/null +++ b/packages/base/src/dialogs/components/symbology/SimpleSymbol.tsx @@ -0,0 +1,243 @@ +import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; +import { FlatStyle } from 'ol/style/flat'; +import React, { useEffect, useRef, useState } from 'react'; +import { IParsedStyle, parseColor } from '../../../tools'; +import { ISymbologyDialogProps } from '../../symbologyDialog'; + +const SimpleSymbol = ({ + context, + state, + okSignalPromise, + cancel, + layerId +}: ISymbologyDialogProps) => { + const styleRef = useRef(); + const layerStateRef = useRef(); + + const [layerState, setLayerState] = useState< + ReadonlyPartialJSONObject | undefined + >(); + const [useCircleStuff, setUseCircleStuff] = useState(false); + const [style, setStyle] = useState({ + fillColor: '#3399CC', + joinStyle: 'round', + strokeColor: '#3399CC', + capStyle: 'round', + strokeWidth: 1.25, + radius: 5 + }); + + const joinStyleOptions = ['bevel', 'round', 'miter']; + const capStyleOptions = ['butt', 'round', 'square']; + + if (!layerId) { + return; + } + const layer = context.model.getLayer(layerId); + if (!layer) { + return; + } + + useEffect(() => { + if (!layer.parameters) { + return; + } + + setUseCircleStuff(layer.parameters.type === 'circle'); + + // Mimicking QGIS here, + // Read values from file if we chose them using the single symbol thing + // but if we're switching to simple symbol, use defaults + const initStyle = async () => { + if (!layer.parameters) { + return; + } + const layerState = await state.fetch(`jupytergis:${layerId}`); + if (!layerState) { + return; + } + + setLayerState(layerState as ReadonlyPartialJSONObject); + + const renderType = (layerState as ReadonlyPartialJSONObject) + .renderType as string; + + if (renderType === 'Single Symbol') { + // Read from current color or use defaults + const parsedStyle = parseColor( + layer.parameters.type, + layer.parameters.color + ); + + if (parsedStyle) { + setStyle(parsedStyle); + } + } + }; + initStyle(); + + okSignalPromise.promise.then(okSignal => { + okSignal.connect(handleOk, this); + }); + + return () => { + okSignalPromise.promise.then(okSignal => { + okSignal.disconnect(handleOk, this); + }); + }; + }, []); + + useEffect(() => { + styleRef.current = style; + layerStateRef.current = layerState; + }, [style, layerState]); + + const handleOk = () => { + if (!layer.parameters) { + return; + } + + state.save(`jupytergis:${layerId}`, { + ...layerStateRef.current, + renderType: 'Single Symbol' + }); + + const styleExpr: FlatStyle = {}; + + const prefix = layer.parameters.type === 'circle' ? 'circle-' : ''; + + if (layer.parameters.type === 'circle') { + styleExpr['circle-radius'] = styleRef.current?.radius; + } + + styleExpr[`${prefix}fill-color`] = styleRef.current?.fillColor; + styleExpr[`${prefix}stroke-color`] = styleRef.current?.strokeColor; + styleExpr[`${prefix}stroke-width`] = styleRef.current?.strokeWidth; + styleExpr[`${prefix}stroke-line-join`] = styleRef.current?.joinStyle; + styleExpr[`${prefix}stroke-line-cap`] = styleRef.current?.capStyle; + + layer.parameters.color = styleExpr; + + context.model.sharedModel.updateLayer(layerId, layer); + cancel(); + }; + + return ( +
+ {useCircleStuff ? ( +
+ + + setStyle(prevState => ({ + ...prevState, + radius: +event.target.value + })) + } + /> +
+ ) : null} +
+ + + setStyle(prevState => ({ + ...prevState, + fillColor: event.target.value + })) + } + /> +
+
+ + + setStyle(prevState => ({ + ...prevState, + strokeColor: event.target.value + })) + } + /> +
+
+ + + setStyle(prevState => ({ + ...prevState, + strokeWidth: +event.target.value + })) + } + /> +
+
+ +
+ +
+
+ {useCircleStuff ? ( +
+ +
+ +
+
+ ) : null} +
+ ); +}; + +export default SimpleSymbol; diff --git a/packages/base/src/dialogs/components/symbology/SingleBandPseudoColor.tsx b/packages/base/src/dialogs/components/symbology/SingleBandPseudoColor.tsx index 2d494607..4287e38f 100644 --- a/packages/base/src/dialogs/components/symbology/SingleBandPseudoColor.tsx +++ b/packages/base/src/dialogs/components/symbology/SingleBandPseudoColor.tsx @@ -3,18 +3,14 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { IDict } from '@jupytergis/schema'; import { PageConfig } from '@jupyterlab/coreutils'; import { Button } from '@jupyterlab/ui-components'; +import { ReadonlyPartialJSONObject } from '@lumino/coreutils'; import initGdalJs from 'gdal3.js'; import { ExpressionValue } from 'ol/expr/expression'; import React, { useEffect, useRef, useState } from 'react'; -import { ISymbologyDialogProps } from '../../symbologyDialog'; +import { IStopRow, ISymbologyDialogProps } from '../../symbologyDialog'; import BandRow from './BandRow'; import StopRow from './StopRow'; -export interface IStopRow { - value: number; - color: number[]; -} - export interface IBandRow { band: number; colorInterpretation: string; @@ -118,9 +114,11 @@ const SingleBandPseudoColor = ({ const baseUrl = PageConfig.getBaseUrl(); - const tifDataState = (await state.fetch(layerId)) as string; - if (tifDataState) { - tifData = JSON.parse(tifDataState); + const layerState = await state.fetch(`jupytergis:${layerId}`); + if (layerState) { + tifData = JSON.parse( + (layerState as ReadonlyPartialJSONObject).tifData as string + ); } else { //! This takes so long, maybe do when adding source instead const Gdal = await initGdalJs({ @@ -136,7 +134,7 @@ const SingleBandPseudoColor = ({ tifData = await Gdal.gdalinfo(tifDataset, ['-stats']); Gdal.close(tifDataset); - state.save(layerId, JSON.stringify(tifData)); + state.save(`jupytergis:${layerId}`, { tifData: JSON.stringify(tifData) }); } tifData['bands'].forEach((bandData: TifBandData) => { @@ -180,8 +178,8 @@ const SingleBandPseudoColor = ({ // Sixth and on is value:color pairs for (let i = 5; i < color.length; i += 2) { const obj: IStopRow = { - value: scaleValue(color[i]), - color: color[i + 1] + stop: scaleValue(color[i]), + output: color[i + 1] }; valueColorPairs.push(obj); } @@ -197,8 +195,8 @@ const SingleBandPseudoColor = ({ // Last element is fallback value for (let i = 3; i < color.length - 1; i += 2) { const obj: IStopRow = { - value: scaleValue(color[i][2]), - color: color[i + 1] + stop: scaleValue(color[i][2]), + output: color[i + 1] }; valueColorPairs.push(obj); } @@ -248,8 +246,8 @@ const SingleBandPseudoColor = ({ colorExpr.push(0.0, [0.0, 0.0, 0.0, 0.0]); stopRowsRef.current?.map(stop => { - colorExpr.push(unscaleValue(stop.value)); - colorExpr.push(stop.color); + colorExpr.push(unscaleValue(stop.stop)); + colorExpr.push(stop.output); }); break; @@ -266,9 +264,9 @@ const SingleBandPseudoColor = ({ colorExpr.push([ '<=', ['band', selectedBand], - unscaleValue(stop.value) + unscaleValue(stop.stop) ]); - colorExpr.push(stop.color); + colorExpr.push(stop.output); }); // fallback value @@ -286,9 +284,9 @@ const SingleBandPseudoColor = ({ colorExpr.push([ '==', ['band', selectedBand], - unscaleValue(stop.value) + unscaleValue(stop.stop) ]); - colorExpr.push(stop.color); + colorExpr.push(stop.output); }); // fallback value @@ -309,8 +307,8 @@ const SingleBandPseudoColor = ({ const addStopRow = () => { setStopRows([ { - value: 0, - color: [0, 0, 0, 1] + stop: 0, + output: [0, 0, 0, 1] }, ...stopRows ]); @@ -401,10 +399,10 @@ const SingleBandPseudoColor = ({ {stopRows.map((stop, index) => ( deleteStopRow(index)} @@ -418,7 +416,6 @@ const SingleBandPseudoColor = ({ > Add Stop - {/* */} ); diff --git a/packages/base/src/dialogs/components/symbology/StopRow.tsx b/packages/base/src/dialogs/components/symbology/StopRow.tsx index 2b5905a6..2548aab5 100644 --- a/packages/base/src/dialogs/components/symbology/StopRow.tsx +++ b/packages/base/src/dialogs/components/symbology/StopRow.tsx @@ -2,7 +2,7 @@ import { faTrash } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Button } from '@jupyterlab/ui-components'; import React from 'react'; -import { IStopRow } from './SingleBandPseudoColor'; +import { IStopRow } from '../../symbologyDialog'; const StopRow = ({ index, @@ -10,16 +10,22 @@ const StopRow = ({ outputValue, stopRows, setStopRows, - deleteRow + deleteRow, + useNumber }: { index: number; value: number; - outputValue: number[]; + outputValue: number | number[]; stopRows: IStopRow[]; setStopRows: (stopRows: IStopRow[]) => void; deleteRow: () => void; + useNumber?: boolean; }) => { - const rgbArrToHex = (rgbArr: number[]) => { + const rgbArrToHex = (rgbArr: number | number[]) => { + if (!Array.isArray(rgbArr)) { + return; + } + const hex = rgbArr .slice(0, -1) // Color input doesn't support hex alpha values so cut that out .map((val: { toString: (arg0: number) => string }) => { @@ -47,19 +53,19 @@ const StopRow = ({ return rgbValues; }; - const handleValueChange = (event: { target: { value: string | number } }) => { + const handleStopChange = (event: { target: { value: string | number } }) => { const newRows = [...stopRows]; - newRows[index].value = +event.target.value; + newRows[index].stop = +event.target.value; setStopRows(newRows); }; const handleBlur = () => { const newRows = [...stopRows]; newRows.sort((a, b) => { - if (a.value < b.value) { + if (a.stop < b.stop) { return -1; } - if (a.value > b.value) { + if (a.stop > b.stop) { return 1; } return 0; @@ -67,9 +73,11 @@ const StopRow = ({ setStopRows(newRows); }; - const handleColorChange = (event: { target: { value: any } }) => { + const handleOutputChange = (event: { target: { value: any } }) => { const newRows = [...stopRows]; - newRows[index].color = hexToRgb(event.target.value); + useNumber + ? (newRows[index].output = +event.target.value) + : (newRows[index].output = hexToRgb(event.target.value)); setStopRows(newRows); }; @@ -79,18 +87,27 @@ const StopRow = ({ id={`jp-gis-color-value-${index}`} type="number" value={value} - onChange={handleValueChange} + onChange={handleStopChange} onBlur={handleBlur} - className="jp-mod-styled" + className="jp-mod-styled jp-gis-color-row-value-input" /> - + {useNumber ? ( + + ) : ( + + )}