diff --git a/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx b/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx
index 177e3561b..42a8d2048 100644
--- a/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx
+++ b/packages/base/src/dialogs/symbology/tiff_layer/types/SingleBandPseudoColor.tsx
@@ -2,40 +2,55 @@ import { IWebGlLayer } from '@jupytergis/schema';
import { Button } from '@jupyterlab/ui-components';
import { ReadonlyJSONObject } from '@lumino/coreutils';
import { ExpressionValue } from 'ol/expr/expression';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useState } from 'react';
import { GeoTiffClassifications } from '@/src/dialogs/symbology/classificationModes';
import ColorRampControls, {
ColorRampControlsOptions,
} from '@/src/dialogs/symbology/components/color_ramp/ColorRampControls';
import StopRow from '@/src/dialogs/symbology/components/color_stops/StopRow';
-import useGetBandInfo, {
- IBandRow,
-} from '@/src/dialogs/symbology/hooks/useGetBandInfo';
+import useGetBandInfo from '@/src/dialogs/symbology/hooks/useGetBandInfo';
+import { useOkSignal } from '@/src/dialogs/symbology/hooks/useOkSignal';
import {
IStopRow,
ISymbologyDialogProps,
} from '@/src/dialogs/symbology/symbologyDialog';
-import { Utils } from '@/src/dialogs/symbology/symbologyUtils';
+import {
+ saveSymbology,
+ Utils,
+ WebGlSymbologyParams,
+} from '@/src/dialogs/symbology/symbologyUtils';
import BandRow from '@/src/dialogs/symbology/tiff_layer/components/BandRow';
import { LoadingOverlay } from '@/src/shared/components/loading';
+import { useLatest } from '@/src/shared/hooks/useLatest';
import { GlobalStateDbManager } from '@/src/store';
import { ClassificationMode } from '@/src/types';
import { ColorRampName } from '../../colorRampUtils';
+import { useEffectiveSymbologyParams } from '../../hooks/useEffectiveSymbologyParams';
export type InterpolationType = 'discrete' | 'linear' | 'exact';
const SingleBandPseudoColor: React.FC = ({
model,
okSignalPromise,
- cancel,
layerId,
+ isStorySegmentOverride,
+ segmentId,
}) => {
if (!layerId) {
return;
}
const layer = model.getLayer(layerId);
- if (!layer?.parameters) {
+
+ const params = useEffectiveSymbologyParams({
+ model,
+ layerId: layerId,
+ layer,
+ isStorySegmentOverride,
+ segmentId,
+ });
+
+ if (!params || !layer) {
return;
}
@@ -59,38 +74,20 @@ const SingleBandPseudoColor: React.FC = ({
ColorRampControlsOptions | undefined
>();
- const stopRowsRef = useRef();
- const bandRowsRef = useRef([]);
- const selectedFunctionRef = useRef();
- const colorRampOptionsRef = useRef();
- const selectedBandRef = useRef();
+ const stopRowsRef = useLatest(stopRows);
+ const bandRowsRef = useLatest(bandRows);
+ const selectedFunctionRef = useLatest(selectedFunction);
+ const colorRampOptionsRef = useLatest(colorRampOptions);
+ const selectedBandRef = useLatest(selectedBand);
useEffect(() => {
populateOptions();
-
- okSignalPromise.promise.then(okSignal => {
- okSignal.connect(handleOk);
- });
-
- return () => {
- okSignalPromise.promise.then(okSignal => {
- okSignal.disconnect(handleOk, this);
- });
- };
}, []);
useEffect(() => {
- bandRowsRef.current = bandRows;
buildColorInfo();
}, [bandRows]);
- useEffect(() => {
- stopRowsRef.current = stopRows;
- selectedFunctionRef.current = selectedFunction;
- colorRampOptionsRef.current = colorRampOptions;
- selectedBandRef.current = selectedBand;
- }, [stopRows, selectedFunction, colorRampOptions, selectedBand, layerState]);
-
const populateOptions = async () => {
const layerState = (await stateDb?.fetch(
`jupytergis:${layerId}`,
@@ -98,9 +95,8 @@ const SingleBandPseudoColor: React.FC = ({
setLayerState(layerState);
- const layerParams = layer.parameters as IWebGlLayer;
- const band = layerParams.symbologyState?.band ?? 1;
- const interpolation = layerParams.symbologyState?.interpolation ?? 'linear';
+ const band = params.symbologyState?.band ?? 1;
+ const interpolation = params.symbologyState?.interpolation ?? 'linear';
setSelectedBand(band);
setSelectedFunction(interpolation);
@@ -108,22 +104,23 @@ const SingleBandPseudoColor: React.FC = ({
const buildColorInfo = () => {
// This it to parse a color object on the layer
- if (!layer.parameters?.color || !layerState) {
+ if (!params.color || !layerState) {
return;
}
- const color = layer.parameters.color;
+ const color = params.color;
// If color is a string we don't need to parse
- if (typeof color === 'string') {
+ // Otherwise color expression should be an array (e.g. ['interpolate', ...] or ['case', ...])
+ if (!Array.isArray(color)) {
return;
}
+ // ! wtf ? dont use statedb just read from the file??
const isQuantile = (layerState.selectedMode as string) === 'quantile';
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[0]) {
case 'interpolate': {
@@ -134,8 +131,8 @@ const SingleBandPseudoColor: React.FC = ({
// Sixth and on is value:color pairs
for (let i = 5; i < color.length; i += 2) {
const obj: IStopRow = {
- stop: scaleValue(color[i], isQuantile),
- output: color[i + 1],
+ stop: scaleValue(Number(color[i]), isQuantile),
+ output: color[i + 1] as IStopRow['output'],
};
valueColorPairs.push(obj);
}
@@ -150,9 +147,14 @@ const SingleBandPseudoColor: React.FC = ({
// Fifth is color
// Last element is fallback value
for (let i = 3; i < color.length - 1; i += 2) {
+ const stopVal = Number(
+ Array.isArray(color[i])
+ ? (color[i] as (string | number)[])[2]
+ : color[i],
+ );
const obj: IStopRow = {
- stop: scaleValue(color[i][2], isQuantile),
- output: color[i + 1],
+ stop: scaleValue(stopVal, isQuantile),
+ output: color[i + 1] as IStopRow['output'],
};
valueColorPairs.push(obj);
}
@@ -164,33 +166,13 @@ const SingleBandPseudoColor: React.FC = ({
};
const handleOk = () => {
- // Update source
const bandRow = bandRowsRef.current[selectedBand - 1];
if (!bandRow) {
return;
}
- const sourceId = layer.parameters?.source;
- const source = model.getSource(sourceId);
-
- if (!source || !source.parameters) {
- return;
- }
const isQuantile = colorRampOptionsRef.current?.selectedMode === 'quantile';
- const sourceInfo = source.parameters.urls[0];
- sourceInfo.min = bandRow.stats.minimum;
- sourceInfo.max = bandRow.stats.maximum;
-
- source.parameters.urls[0] = sourceInfo;
-
- model.sharedModel.updateSource(sourceId, source);
-
- // Update layer
- if (!layer.parameters) {
- return;
- }
-
// TODO: Different viewers will have different types
let colorExpr: ExpressionValue[] = [];
@@ -258,18 +240,46 @@ const SingleBandPseudoColor: React.FC = ({
band: selectedBandRef.current,
interpolation: selectedFunctionRef.current,
colorRamp: colorRampOptionsRef.current?.selectedRamp,
- nClasses: colorRampOptionsRef.current?.numberOfShades,
+ nClasses:
+ colorRampOptionsRef.current?.numberOfShades !== undefined
+ ? String(colorRampOptionsRef.current.numberOfShades)
+ : undefined,
mode: colorRampOptionsRef.current?.selectedMode,
- };
+ } as IWebGlLayer['symbologyState'];
+
+ if (!isStorySegmentOverride) {
+ // Update source
+ const sourceId = layer?.parameters?.source;
+ const source = model.getSource(sourceId);
+ if (!source || !source.parameters) {
+ return;
+ }
- layer.parameters.symbologyState = symbologyState;
- layer.parameters.color = colorExpr;
- layer.type = 'WebGlLayer';
+ const sourceInfo = source.parameters.urls[0];
+ sourceInfo.min = bandRow.stats.minimum;
+ sourceInfo.max = bandRow.stats.maximum;
- model.sharedModel.updateLayer(layerId, layer);
- cancel();
+ source.parameters.urls[0] = sourceInfo;
+ model.sharedModel.updateSource(sourceId, source);
+ }
+
+ saveSymbology({
+ model,
+ layerId,
+ isStorySegmentOverride,
+ segmentId,
+ payload: {
+ symbologyState,
+ color: colorExpr,
+ },
+ mutateLayerBeforeSave: targetLayer => {
+ targetLayer.type = 'WebGlLayer';
+ },
+ });
};
+ useOkSignal(okSignalPromise, handleOk);
+
const addStopRow = () => {
setStopRows([
{
@@ -411,7 +421,7 @@ const SingleBandPseudoColor: React.FC = ({
{bandRows.length > 0 && (
- >,
-) =>
- useEffect(() => {
- let renderType = layer.parameters?.symbologyState?.renderType;
- if (!renderType) {
- renderType = layer.type === 'HeatmapLayer' ? 'Heatmap' : 'Single Symbol';
- }
- setSelectedRenderType(renderType);
- }, []);
-
const VectorRendering: React.FC = ({
model,
- state,
okSignalPromise,
- cancel,
layerId,
+ isStorySegmentOverride = false,
+ segmentId,
}) => {
+ const [symbologyTab, setSymbologyTab] = useState('color');
const [selectedRenderType, setSelectedRenderType] = useState<
VectorRenderType | undefined
>();
- const [symbologyTab, setSymbologyTab] = useState('color');
- if (!layerId) {
- return;
- }
- const layer = model.getLayer(layerId);
- if (!layer?.parameters) {
- return;
- }
+ const layer = layerId !== undefined ? model.getLayer(layerId) : null;
+
+ useEffect(() => {
+ if (!layer) {
+ return;
+ }
+
+ let renderType: VectorRenderType | undefined;
+
+ if (isStorySegmentOverride) {
+ const segment = segmentId ? model.getLayer(segmentId) : undefined;
+ if (!segment) {
+ return;
+ }
+ const override = segment.parameters?.layerOverride?.find(
+ (override: { targetLayer?: string }) =>
+ override.targetLayer === layerId,
+ );
+ if (!override) {
+ return;
+ }
+
+ renderType = override.symbologyState?.renderType;
+ } else {
+ renderType = layer.parameters?.symbologyState?.renderType;
+ }
+
+ if (!renderType) {
+ renderType = layer.type === 'HeatmapLayer' ? 'Heatmap' : 'Single Symbol';
+ }
+
+ setSelectedRenderType(renderType);
+ }, []);
const { featureProperties, isLoading: featuresLoading } = useGetProperties({
layerId,
model: model,
});
- useLayerRenderType(layer, setSelectedRenderType);
+ if (!layerId || !layer?.parameters) {
+ return null;
+ }
if (featuresLoading) {
return Loading...
;
}
if (selectedRenderType === undefined) {
- // typeguard
- return;
+ return null;
}
const selectableRenderTypes = getSelectableRenderTypes(
@@ -188,10 +201,10 @@ const VectorRendering: React.FC = ({
= ({
model,
- state,
okSignalPromise,
- cancel,
layerId,
selectableAttributesAndValues,
+ isStorySegmentOverride,
+ segmentId,
}) => {
- const selectedValueRef = useRef();
const [selectedValue, setSelectedValue] = useState('');
+ const selectedValueRef = useLatest(selectedValue);
if (!layerId) {
return;
@@ -24,18 +27,6 @@ const Canonical: React.FC = ({
return;
}
- useEffect(() => {
- okSignalPromise.promise.then(okSignal => {
- okSignal.connect(handleOk, this);
- });
-
- return () => {
- okSignalPromise.promise.then(okSignal => {
- okSignal.disconnect(handleOk, this);
- });
- };
- }, [selectedValue]);
-
useEffect(() => {
const layerParams = layer.parameters as IVectorLayer;
const value =
@@ -45,16 +36,12 @@ const Canonical: React.FC = ({
setSelectedValue(value);
}, [selectableAttributesAndValues]);
- useEffect(() => {
- selectedValueRef.current = selectedValue;
- }, [selectedValue]);
-
const handleOk = () => {
if (!layer.parameters) {
return;
}
- const colorExpr: ExpressionValue[] = ['get', selectedValue];
+ const colorExpr: ExpressionValue[] = ['get', selectedValueRef.current];
const newStyle = { ...layer.parameters.color };
newStyle['fill-color'] = colorExpr;
newStyle['stroke-color'] = colorExpr;
@@ -65,16 +52,25 @@ const Canonical: React.FC = ({
value: selectedValueRef.current,
};
- layer.parameters.symbologyState = symbologyState;
- layer.parameters.color = newStyle;
- if (layer.type === 'HeatmapLayer') {
- layer.type = 'VectorLayer';
- }
-
- model.sharedModel.updateLayer(layerId, layer);
- cancel();
+ saveSymbology({
+ model,
+ layerId,
+ isStorySegmentOverride,
+ segmentId,
+ payload: {
+ symbologyState,
+ color: newStyle,
+ },
+ mutateLayerBeforeSave: targetLayer => {
+ if (targetLayer.type === 'HeatmapLayer') {
+ targetLayer.type = 'VectorLayer';
+ }
+ },
+ });
};
+ useOkSignal(okSignalPromise, handleOk);
+
const body = (() => {
if (Object.keys(selectableAttributesAndValues)?.length === 0) {
return (
diff --git a/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx b/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx
index f9baeede3..400e6d6aa 100644
--- a/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx
+++ b/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx
@@ -1,32 +1,36 @@
import { IVectorLayer } from '@jupytergis/schema';
import { ReadonlyJSONObject } from '@lumino/coreutils';
import { ExpressionValue } from 'ol/expr/expression';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useState } from 'react';
import ColorRampControls from '@/src/dialogs/symbology/components/color_ramp/ColorRampControls';
import StopContainer from '@/src/dialogs/symbology/components/color_stops/StopContainer';
+import { useOkSignal } from '@/src/dialogs/symbology/hooks/useOkSignal';
import {
IStopRow,
ISymbologyTabbedDialogWithAttributesProps,
} from '@/src/dialogs/symbology/symbologyDialog';
-import { Utils, VectorUtils } from '@/src/dialogs/symbology/symbologyUtils';
+import {
+ Utils,
+ VectorSymbologyParams,
+ VectorUtils,
+ saveSymbology,
+} from '@/src/dialogs/symbology/symbologyUtils';
import ValueSelect from '@/src/dialogs/symbology/vector_layer/components/ValueSelect';
+import { useLatest } from '@/src/shared/hooks/useLatest';
import { SymbologyTab, ClassificationMode } from '@/src/types';
import { ColorRampName } from '../../colorRampUtils';
+import { useEffectiveSymbologyParams } from '../../hooks/useEffectiveSymbologyParams';
const Categorized: React.FC = ({
model,
- state,
okSignalPromise,
- cancel,
layerId,
symbologyTab,
selectableAttributesAndValues,
+ isStorySegmentOverride,
+ segmentId,
}) => {
- const selectedAttributeRef = useRef();
- const stopRowsRef = useRef();
- const colorRampOptionsRef = useRef();
-
const [selectedAttribute, setSelectedAttribute] = useState('');
const [stopRows, setStopRows] = useState([]);
const [colorRampOptions, setColorRampOptions] = useState<
@@ -38,39 +42,41 @@ const Categorized: React.FC = ({
strokeWidth: 1.25,
radius: 5,
});
- const manualStyleRef = useRef(manualStyle);
+ const manualStyleRef = useLatest(manualStyle);
const [reverseRamp, setReverseRamp] = useState(false);
+ const selectedAttributeRef = useLatest(selectedAttribute);
+ const stopRowsRef = useLatest(stopRows);
+ const colorRampOptionsRef = useLatest(colorRampOptions);
if (!layerId) {
return;
}
const layer = model.getLayer(layerId);
- if (!layer?.parameters) {
+
+ const params = useEffectiveSymbologyParams({
+ model,
+ layerId: layerId,
+ layer,
+ isStorySegmentOverride,
+ segmentId,
+ });
+
+ if (!params) {
return;
}
useEffect(() => {
- const valueColorPairs = VectorUtils.buildColorInfo(layer);
+ const valueColorPairs = VectorUtils.buildColorInfo(params);
setStopRows(valueColorPairs);
-
- okSignalPromise.promise.then(okSignal => {
- okSignal.connect(handleOk, this);
- });
-
- return () => {
- okSignalPromise.promise.then(okSignal => {
- okSignal.disconnect(handleOk, this);
- });
- };
}, []);
useEffect(() => {
- if (layer?.parameters?.color) {
- const fillColor = layer.parameters.color['fill-color'];
- const circleFillColor = layer.parameters.color['circle-fill-color'];
- const strokeColor = layer.parameters.color['stroke-color'];
- const circleStrokeColor = layer.parameters.color['circle-stroke-color'];
+ if (params.color) {
+ const fillColor = params.color['fill-color'];
+ const circleFillColor = params.color['circle-fill-color'];
+ const strokeColor = params.color['stroke-color'];
+ const circleStrokeColor = params.color['circle-stroke-color'];
const isSimpleColor = (val: any) =>
typeof val === 'string' && /^#?[0-9A-Fa-f]{3,8}$/.test(val);
@@ -89,34 +95,23 @@ const Categorized: React.FC = ({
: '#3399CC',
strokeWidth:
- layer.parameters.color['stroke-width'] ||
- layer.parameters.color['circle-stroke-width'] ||
+ params.color['stroke-width'] ||
+ params.color['circle-stroke-width'] ||
1.25,
- radius: layer.parameters.color['circle-radius'] || 5,
+ radius: params.color['circle-radius'] || 5,
});
}
}, [layerId]);
- useEffect(() => {
- manualStyleRef.current = manualStyle;
- }, [manualStyle]);
-
useEffect(() => {
// We only want number values here
- const layerParams = layer.parameters as IVectorLayer;
const attribute =
- layerParams.symbologyState?.value ??
+ params.symbologyState?.value ??
Object.keys(selectableAttributesAndValues)[0];
setSelectedAttribute(attribute);
}, [selectableAttributesAndValues]);
- useEffect(() => {
- selectedAttributeRef.current = selectedAttribute;
- stopRowsRef.current = stopRows;
- colorRampOptionsRef.current = colorRampOptions;
- }, [selectedAttribute, stopRows, colorRampOptions]);
-
const buildColorInfoFromClassification = (
selectedMode: ClassificationMode,
numberOfShades: number,
@@ -145,11 +140,7 @@ const Categorized: React.FC = ({
};
const handleOk = () => {
- if (!layer.parameters) {
- return;
- }
-
- const newStyle = { ...layer.parameters.color };
+ const newStyle = { ...params.color };
if (stopRowsRef.current && stopRowsRef.current.length > 0) {
// Classification applied (for color)
@@ -184,25 +175,33 @@ const Categorized: React.FC = ({
colorRamp: colorRampOptionsRef.current?.selectedRamp,
symbologyTab,
reverse: reverseRamp,
- };
-
- layer.parameters.symbologyState = symbologyState;
- layer.parameters.color = newStyle;
-
- if (layer.type === 'HeatmapLayer') {
- layer.type = 'VectorLayer';
- }
-
- model.sharedModel.updateLayer(layerId, layer);
- cancel();
+ } as IVectorLayer['symbologyState'];
+
+ saveSymbology({
+ model,
+ layerId,
+ isStorySegmentOverride,
+ segmentId,
+ payload: {
+ symbologyState,
+ color: newStyle,
+ },
+ mutateLayerBeforeSave: targetLayer => {
+ if (targetLayer.type === 'HeatmapLayer') {
+ targetLayer.type = 'VectorLayer';
+ }
+ },
+ });
};
+ useOkSignal(okSignalPromise, handleOk);
+
const handleReset = (method: SymbologyTab) => {
if (!layer?.parameters) {
return;
}
- const newStyle = { ...layer.parameters.color };
+ const newStyle = { ...params.color };
if (method === 'color') {
delete newStyle['fill-color'];
@@ -325,8 +324,9 @@ const Categorized: React.FC = ({
)}
+ //! only needs symbology state
= ({
model,
- state,
okSignalPromise,
- cancel,
layerId,
symbologyTab,
selectableAttributesAndValues,
+ isStorySegmentOverride,
+ segmentId,
}) => {
const modeOptions = [
'quantile',
@@ -33,12 +41,6 @@ const Graduated: React.FC = ({
'logarithmic',
] as const satisfies ClassificationMode[];
- const selectableAttributeRef = useRef();
- const symbologyTabRef = useRef();
- const colorStopRowsRef = useRef([]);
- const radiusStopRowsRef = useRef([]);
- const colorRampOptionsRef = useRef();
-
const [selectedAttribute, setSelectedAttribute] = useState('');
const [colorStopRows, setColorStopRows] = useState([]);
const [radiusStopRows, setRadiusStopRows] = useState([]);
@@ -54,35 +56,38 @@ const Graduated: React.FC = ({
});
const [reverseRamp, setReverseRamp] = useState(false);
- const colorManualStyleRef = useRef(colorManualStyle);
- const radiusManualStyleRef = useRef(radiusManualStyle);
+ const selectableAttributeRef = useLatest(selectedAttribute);
+ const symbologyTabRef = useLatest(symbologyTab);
+ const colorStopRowsRef = useLatest(colorStopRows);
+ const radiusStopRowsRef = useLatest(radiusStopRows);
+ const colorRampOptionsRef = useLatest(colorRampOptions);
+
+ const colorManualStyleRef = useLatest(colorManualStyle);
+ const radiusManualStyleRef = useLatest(radiusManualStyle);
if (!layerId) {
return;
}
const layer = model.getLayer(layerId);
- if (!layer?.parameters) {
+ const params = useEffectiveSymbologyParams({
+ model,
+ layerId: layerId,
+ layer,
+ isStorySegmentOverride,
+ segmentId,
+ });
+ if (!params) {
return;
}
useEffect(() => {
updateStopRowsBasedOnLayer();
-
- okSignalPromise.promise.then(okSignal => {
- okSignal.connect(handleOk, this);
- });
-
- return () => {
- okSignalPromise.promise.then(okSignal => {
- okSignal.disconnect(handleOk, this);
- });
- };
}, []);
useEffect(() => {
- if (layer?.parameters?.color) {
- const strokeColor = layer.parameters.color['stroke-color'];
- const circleStrokeColor = layer.parameters.color['circle-stroke-color'];
+ if (params.color) {
+ const strokeColor = params.color['stroke-color'];
+ const circleStrokeColor = params.color['circle-stroke-color'];
const isSimpleColor = (val: any) =>
typeof val === 'string' && /^#?[0-9A-Fa-f]{3,8}$/.test(val);
@@ -94,39 +99,19 @@ const Graduated: React.FC = ({
? circleStrokeColor
: '#3399CC',
strokeWidth:
- layer.parameters.color['stroke-width'] ||
- layer.parameters.color['circle-stroke-width'] ||
+ params.color['stroke-width'] ||
+ params.color['circle-stroke-width'] ||
1.25,
});
setRadiusManualStyle({
- radius: layer.parameters.color['circle-radius'] || 5,
+ radius: params.color['circle-radius'] || 5,
});
}
}, [layerId]);
useEffect(() => {
- colorStopRowsRef.current = colorStopRows;
- radiusStopRowsRef.current = radiusStopRows;
- selectableAttributeRef.current = selectedAttribute;
- symbologyTabRef.current = symbologyTab;
- colorRampOptionsRef.current = colorRampOptions;
- }, [
- colorStopRows,
- radiusStopRows,
- selectedAttribute,
- symbologyTab,
- colorRampOptions,
- ]);
-
- useEffect(() => {
- colorManualStyleRef.current = colorManualStyle;
- radiusManualStyleRef.current = radiusManualStyle;
- }, [colorManualStyle, radiusManualStyle]);
-
- useEffect(() => {
- const layerParams = layer.parameters as IVectorLayer;
const attribute =
- layerParams.symbologyState?.value ??
+ params.symbologyState?.value ??
Object.keys(selectableAttributesAndValues)[0];
setSelectedAttribute(attribute);
@@ -137,16 +122,12 @@ const Graduated: React.FC = ({
return;
}
- setColorStopRows(VectorUtils.buildColorInfo(layer));
+ setColorStopRows(VectorUtils.buildColorInfo(params));
setRadiusStopRows(VectorUtils.buildRadiusInfo(layer));
};
const handleOk = () => {
- if (!layer.parameters) {
- return;
- }
-
- const newStyle = { ...layer.parameters.color };
+ const newStyle = { ...params.color };
// Apply color symbology
if (colorStopRowsRef.current.length > 0) {
@@ -188,8 +169,7 @@ const Graduated: React.FC = ({
newStyle['circle-radius'] = radiusManualStyleRef.current.radius;
}
- layer.parameters.color = newStyle;
- layer.parameters.symbologyState = {
+ const symbologyState = {
renderType: 'Graduated',
value: selectableAttributeRef.current,
method: symbologyTabRef.current,
@@ -197,16 +177,27 @@ const Graduated: React.FC = ({
nClasses: colorRampOptionsRef.current?.numberOfShades,
mode: colorRampOptionsRef.current?.selectedMode,
reverse: reverseRamp,
- };
-
- if (layer.type === 'HeatmapLayer') {
- layer.type = 'VectorLayer';
- }
-
- model.sharedModel.updateLayer(layerId, layer);
- cancel();
+ } as IVectorLayer['symbologyState'];
+
+ saveSymbology({
+ model,
+ layerId,
+ isStorySegmentOverride,
+ segmentId,
+ payload: {
+ symbologyState,
+ color: newStyle,
+ },
+ mutateLayerBeforeSave: targetLayer => {
+ if (targetLayer.type === 'HeatmapLayer') {
+ targetLayer.type = 'VectorLayer';
+ }
+ },
+ });
};
+ useOkSignal(okSignalPromise, handleOk);
+
const buildColorInfoFromClassification = (
selectedMode: ClassificationMode,
numberOfShades: number,
@@ -276,11 +267,7 @@ const Graduated: React.FC = ({
};
const handleReset = (method: string) => {
- if (!layer?.parameters) {
- return;
- }
-
- const newStyle = { ...layer.parameters.color };
+ const newStyle = { ...params.color };
if (method === 'color') {
delete newStyle['stroke-color'];
@@ -293,6 +280,11 @@ const Graduated: React.FC = ({
setRadiusStopRows([]);
}
+ const layer = model.getLayer(layerId);
+ if (!layer?.parameters) {
+ return;
+ }
+
layer.parameters.color = newStyle;
model.sharedModel.updateLayer(layerId, layer);
};
@@ -384,7 +376,7 @@ const Graduated: React.FC = ({
)}
= ({
model,
- state,
okSignalPromise,
- cancel,
layerId,
+ isStorySegmentOverride,
+ segmentId,
}) => {
if (!layerId) {
return;
}
const layer = model.getLayer(layerId);
- if (!layer?.parameters) {
+
+ const params = useEffectiveSymbologyParams({
+ model,
+ layerId: layerId,
+ layer,
+ isStorySegmentOverride,
+ segmentId,
+ });
+
+ if (!params) {
return;
}
+
const [selectedRamp, setSelectedRamp] = useState('viridis');
const [heatmapOptions, setHetamapOptions] = useState({
radius: 8,
@@ -26,48 +43,25 @@ const Heatmap: React.FC = ({
});
const [reverseRamp, setReverseRamp] = useState(false);
- const selectedRampRef = useRef('viridis');
- const heatmapOptionsRef = useRef({
- radius: 8,
- blur: 15,
- });
- const reverseRampRef = useRef(false);
+ const selectedRampRef = useLatest(selectedRamp);
+ const heatmapOptionsRef = useLatest(heatmapOptions);
+ const reverseRampRef = useLatest(reverseRamp);
useEffect(() => {
populateOptions();
-
- okSignalPromise.promise.then(okSignal => {
- okSignal.connect(handleOk, this);
- });
-
- return () => {
- okSignalPromise.promise.then(okSignal => {
- okSignal.disconnect(handleOk, this);
- });
- };
}, []);
- useEffect(() => {
- selectedRampRef.current = selectedRamp;
- heatmapOptionsRef.current = heatmapOptions;
- reverseRampRef.current = reverseRamp;
- }, [selectedRamp, heatmapOptions, reverseRamp]);
-
const populateOptions = async () => {
let colorRamp;
- if (layer.parameters?.symbologyState) {
- colorRamp = layer.parameters.symbologyState.colorRamp;
+ if (params.symbologyState?.colorRamp) {
+ colorRamp = params.symbologyState.colorRamp as ColorRampName;
}
setSelectedRamp(colorRamp ? colorRamp : 'viridis');
};
const handleOk = () => {
- if (!layer.parameters) {
- return;
- }
-
let colorMap = colormap({
colormap: selectedRampRef.current,
nshades: 9,
@@ -84,17 +78,25 @@ const Heatmap: React.FC = ({
reverse: reverseRampRef.current,
};
- layer.parameters.symbologyState = symbologyState;
- layer.parameters.color = colorMap;
- layer.parameters.blur = heatmapOptionsRef.current.blur;
- layer.parameters.radius = heatmapOptionsRef.current.radius;
- layer.type = 'HeatmapLayer';
-
- model.sharedModel.updateLayer(layerId, layer);
-
- cancel();
+ saveSymbology({
+ model,
+ layerId,
+ isStorySegmentOverride,
+ segmentId,
+ payload: {
+ symbologyState,
+ color: colorMap,
+ },
+ mutateLayerBeforeSave: targetLayer => {
+ targetLayer.parameters.blur = heatmapOptionsRef.current.blur;
+ targetLayer.parameters.radius = heatmapOptionsRef.current.radius;
+ targetLayer.type = 'HeatmapLayer';
+ },
+ });
};
+ useOkSignal(okSignalPromise, handleOk);
+
return (
Represent features based on their density using a heatmap.
diff --git a/packages/base/src/dialogs/symbology/vector_layer/types/SimpleSymbol.tsx b/packages/base/src/dialogs/symbology/vector_layer/types/SimpleSymbol.tsx
index 5e6b06ec8..b2f72e582 100644
--- a/packages/base/src/dialogs/symbology/vector_layer/types/SimpleSymbol.tsx
+++ b/packages/base/src/dialogs/symbology/vector_layer/types/SimpleSymbol.tsx
@@ -1,19 +1,24 @@
import { FlatStyle } from 'ol/style/flat';
-import React, { useEffect, useRef, useState } from 'react';
+import React, { useEffect, useState } from 'react';
+import { useEffectiveSymbologyParams } from '@/src/dialogs/symbology/hooks/useEffectiveSymbologyParams';
+import { useOkSignal } from '@/src/dialogs/symbology/hooks/useOkSignal';
import { ISymbologyTabbedDialogProps } from '@/src/dialogs/symbology/symbologyDialog';
+import {
+ saveSymbology,
+ VectorSymbologyParams,
+} from '@/src/dialogs/symbology/symbologyUtils';
+import { useLatest } from '@/src/shared/hooks/useLatest';
import { IParsedStyle, parseColor } from '@/src/tools';
const SimpleSymbol: React.FC
= ({
model,
- state,
okSignalPromise,
- cancel,
layerId,
symbologyTab,
+ isStorySegmentOverride,
+ segmentId,
}) => {
- const styleRef = useRef();
-
const [style, setStyle] = useState({
fillColor: '#3399CC',
joinStyle: 'round',
@@ -22,58 +27,31 @@ const SimpleSymbol: React.FC = ({
strokeWidth: 1.25,
radius: 5,
});
-
- const joinStyleOptions = ['bevel', 'round', 'miter'];
- const capStyleOptions = ['butt', 'round', 'square'];
-
- if (!layerId) {
- return;
- }
- const layer = model.getLayer(layerId);
- if (!layer) {
- return;
- }
+ const styleRef = useLatest(style);
+
+ const layer = layerId !== undefined ? model.getLayer(layerId) : null;
+ const params = useEffectiveSymbologyParams({
+ model,
+ layerId: layerId,
+ layer,
+ isStorySegmentOverride,
+ segmentId,
+ });
useEffect(() => {
- if (!layer.parameters) {
+ if (!params) {
return;
}
-
- const initStyle = async () => {
- if (!layer.parameters) {
- return;
- }
-
- const renderType = layer.parameters?.symbologyState.renderType;
-
- if (renderType === 'Single Symbol') {
- // Parse with fallback logic inside
- const parsedStyle = parseColor(layer.parameters.color);
-
- if (parsedStyle) {
- setStyle(parsedStyle);
- }
+ if (params.symbologyState?.renderType === 'Single Symbol' && params.color) {
+ const parsed = parseColor(params.color);
+ if (parsed) {
+ setStyle(parsed);
}
- };
- initStyle();
-
- okSignalPromise.promise.then(okSignal => {
- okSignal.connect(handleOk, this);
- });
-
- return () => {
- okSignalPromise.promise.then(okSignal => {
- okSignal.disconnect(handleOk, this);
- });
- };
- }, []);
-
- useEffect(() => {
- styleRef.current = style;
- }, [style]);
+ }
+ }, [params]);
const handleOk = () => {
- if (!layer.parameters) {
+ if (!layerId || !layer?.parameters) {
return;
}
@@ -95,16 +73,32 @@ const SimpleSymbol: React.FC = ({
renderType: 'Single Symbol',
};
- layer.parameters.symbologyState = symbologyState;
- layer.parameters.color = styleExpr;
- if (layer.type === 'HeatmapLayer') {
- layer.type = 'VectorLayer';
- }
-
- model.sharedModel.updateLayer(layerId, layer);
- cancel();
+ saveSymbology({
+ model,
+ layerId,
+ isStorySegmentOverride,
+ segmentId,
+ payload: {
+ symbologyState,
+ color: styleExpr,
+ },
+ mutateLayerBeforeSave: targetLayer => {
+ if (targetLayer.type === 'HeatmapLayer') {
+ targetLayer.type = 'VectorLayer';
+ }
+ },
+ });
};
+ useOkSignal(okSignalPromise, handleOk);
+
+ const joinStyleOptions = ['bevel', 'round', 'miter'];
+ const capStyleOptions = ['butt', 'round', 'square'];
+
+ if (!layerId || !layer) {
+ return null;
+ }
+
const renderColorTab = () => (
<>
diff --git a/packages/base/src/formbuilder/objectform/baseform.tsx b/packages/base/src/formbuilder/objectform/baseform.tsx
index e0b01f72d..1549cf950 100644
--- a/packages/base/src/formbuilder/objectform/baseform.tsx
+++ b/packages/base/src/formbuilder/objectform/baseform.tsx
@@ -1,15 +1,21 @@
-import { Slider } from '@jupyter/react-components';
import { IJupyterGISModel } from '@jupytergis/schema';
import { Dialog } from '@jupyterlab/apputils';
import { FormComponent } from '@jupyterlab/ui-components';
import { Signal } from '@lumino/signaling';
import { IChangeEvent, ISubmitEvent } from '@rjsf/core';
-import { RJSFSchema, UiSchema } from '@rjsf/utils';
+import { RegistryFieldsType, RJSFSchema, UiSchema } from '@rjsf/utils';
import validatorAjv8 from '@rjsf/validator-ajv8';
import * as React from 'react';
import { deepCopy } from '@/src/tools';
import { IDict } from '@/src/types';
+import { LayerSelect } from './components/LayerSelect';
+import OpacitySlider from './components/OpacitySlider';
+
+export interface IJupyterGISFormContext
{
+ model: IJupyterGISModel;
+ formData: TFormData;
+}
export interface IBaseFormStates {
schema?: RJSFSchema;
@@ -71,7 +77,13 @@ export interface IBaseFormProps {
}
const WrappedFormComponent: React.FC = props => {
- const { fields, ...rest } = props;
+ const { ...rest } = props;
+
+ const fields: RegistryFieldsType = {
+ opacity: OpacitySlider,
+ layerSelect: LayerSelect,
+ };
+
return (
{
if (k === 'opacity') {
uiSchema[k] = {
- 'ui:field': (props: any) => {
- const [inputValue, setInputValue] = React.useState(
- props.formData.toFixed(1),
- );
-
- React.useEffect(() => {
- setInputValue(props.formData.toFixed(1));
- }, [props.formData]);
-
- const handleSliderChange = (event: CustomEvent) => {
- const target = event.target as any;
- if (target && '_value' in target) {
- const sliderValue = parseFloat(target._value); // Slider value is in 0–10 range
- const normalizedValue = sliderValue / 10; // Normalize to 0.1–1 range
- props.onChange(normalizedValue);
- }
- };
-
- const handleInputChange = (
- event: React.ChangeEvent,
- ) => {
- const value = event.target.value;
- setInputValue(value);
-
- const parsedValue = parseFloat(value);
- if (
- !isNaN(parsedValue) &&
- parsedValue >= 0.1 &&
- parsedValue <= 1
- ) {
- props.onChange(parsedValue);
- }
- };
-
- return (
-
-
-
-
- );
- },
+ 'ui:field': 'opacity',
};
}
@@ -340,6 +289,12 @@ export class BaseForm extends React.Component {
schema={schema}
uiSchema={uiSchema}
formData={formData}
+ formContext={
+ {
+ model: this.props.model,
+ formData,
+ } satisfies IJupyterGISFormContext
+ }
onSubmit={this.onFormSubmit.bind(this)}
onChange={this.onFormChange.bind(this)}
onBlur={this.onFormBlur.bind(this)}
diff --git a/packages/base/src/formbuilder/objectform/components/LayerSelect.tsx b/packages/base/src/formbuilder/objectform/components/LayerSelect.tsx
new file mode 100644
index 000000000..d0309bcb4
--- /dev/null
+++ b/packages/base/src/formbuilder/objectform/components/LayerSelect.tsx
@@ -0,0 +1,76 @@
+import { IJupyterGISModel, IStorySegmentLayer } from '@jupytergis/schema';
+import { FieldProps } from '@rjsf/utils';
+import React from 'react';
+
+function extractlayerOverrideIndex(idSchema: {
+ $id?: string;
+}): number | undefined {
+ const id = idSchema?.$id ?? '';
+ const match = id.match(/layerOverride_(\d+)/);
+ return match ? parseInt(match[1], 10) : undefined;
+}
+
+interface ILayerSelectFormContext {
+ model?: IJupyterGISModel;
+ formData?: IStorySegmentLayer;
+}
+
+/**
+ * Simple select populated with layers (valid types only).
+ * Used as the targetLayer field inside layerOverride array items.
+ */
+export function LayerSelect(props: FieldProps) {
+ const { idSchema, formContext, formData, onChange } = props;
+ const context = formContext as ILayerSelectFormContext | undefined;
+ const model = context?.model;
+ const fullFormData = context?.formData ?? (formData as IStorySegmentLayer);
+
+ const arrayIndex = extractlayerOverrideIndex(idSchema ?? {});
+ const value =
+ arrayIndex !== undefined && fullFormData?.layerOverride?.[arrayIndex]
+ ? (fullFormData.layerOverride[arrayIndex].targetLayer ?? '')
+ : '';
+
+ if (!model) {
+ return null;
+ }
+
+ const layerOverride = fullFormData?.layerOverride ?? [];
+ const currentTargetLayer =
+ arrayIndex !== undefined
+ ? fullFormData?.layerOverride?.[arrayIndex]?.targetLayer
+ : undefined;
+
+ const usedTargetLayerIds = new Set(
+ layerOverride
+ .filter((_: unknown, i: number) => i !== arrayIndex)
+ .map(override => override.targetLayer)
+ .filter(id => id !== undefined && id !== '')
+ .filter(id => id !== currentTargetLayer),
+ );
+
+ const availableLayers = model.getLayers();
+ const optionsList = Object.entries(availableLayers).filter(
+ ([layerId]) => !usedTargetLayerIds.has(layerId),
+ );
+
+ const handleChange = (e: React.ChangeEvent) => {
+ const newValue = e.target.value;
+ onChange(newValue === '' ? undefined : newValue);
+ };
+
+ return (
+
+ );
+}
diff --git a/packages/base/src/formbuilder/objectform/components/OpacitySlider.tsx b/packages/base/src/formbuilder/objectform/components/OpacitySlider.tsx
new file mode 100644
index 000000000..3382c9b47
--- /dev/null
+++ b/packages/base/src/formbuilder/objectform/components/OpacitySlider.tsx
@@ -0,0 +1,64 @@
+import { Slider } from '@jupyter/react-components';
+import { FieldProps } from '@rjsf/utils';
+import React from 'react';
+
+function OpacitySlider({ formData, onChange }: FieldProps) {
+ const [inputValue, setInputValue] = React.useState(
+ formData?.toFixed(1) ?? '1',
+ );
+
+ React.useEffect(() => {
+ const newValue = formData?.toFixed(1) ?? '1';
+ if (newValue !== inputValue) {
+ setInputValue(newValue);
+ }
+ }, [formData]);
+
+ const handleSliderChange = (event: CustomEvent) => {
+ const target = event.target as any;
+ if (target && '_value' in target) {
+ const sliderValue = parseFloat(target._value); // Slider value is in 0–10 range
+ const normalizedValue = sliderValue / 10; // Normalize to 0.1–1 range
+ onChange(normalizedValue);
+ }
+ };
+
+ const handleInputChange = (event: React.ChangeEvent) => {
+ const value = event.target.value;
+ setInputValue(value);
+
+ const parsedValue = parseFloat(value);
+ if (!isNaN(parsedValue) && parsedValue >= 0.1 && parsedValue <= 1) {
+ onChange(parsedValue);
+ }
+ };
+
+ return (
+
+
+
+
+ );
+}
+
+export default OpacitySlider;
diff --git a/packages/base/src/formbuilder/objectform/components/SegmentFormSymbology.tsx b/packages/base/src/formbuilder/objectform/components/SegmentFormSymbology.tsx
new file mode 100644
index 000000000..d147199f6
--- /dev/null
+++ b/packages/base/src/formbuilder/objectform/components/SegmentFormSymbology.tsx
@@ -0,0 +1,112 @@
+import type { IStorySegmentLayer } from '@jupytergis/schema';
+import { ArrayFieldTemplateProps } from '@rjsf/core';
+import React from 'react';
+
+import { SymbologyWidget } from '@/src/dialogs/symbology/symbologyDialog';
+import { Button } from '@/src/shared/components/Button';
+import { GlobalStateDbManager } from '@/src/store';
+import { SYMBOLOGY_VALID_LAYER_TYPES } from '@/src/types';
+import type { IJupyterGISFormContext } from '../baseform';
+
+interface ILayerOverrideItemProps {
+ item: ArrayFieldTemplateProps['items'][0];
+ formContext: IJupyterGISFormContext;
+}
+
+const SELECTION_SETTLE_MS = 100;
+
+function LayerOverrideItem({ item, formContext }: ILayerOverrideItemProps) {
+ const model = formContext?.model;
+ if (!model) {
+ return null;
+ }
+
+ const state = GlobalStateDbManager.getInstance().getStateDb();
+ const currentItem = formContext?.formData?.layerOverride?.[item.index];
+ const targetLayerId = currentItem?.targetLayer;
+ const selectedLayer = targetLayerId
+ ? model.getLayer(targetLayerId)
+ : undefined;
+ const canOpenSymbology = Boolean(
+ targetLayerId &&
+ selectedLayer &&
+ SYMBOLOGY_VALID_LAYER_TYPES.includes(selectedLayer.type),
+ );
+
+ const handleOpenSymbology = async () => {
+ if (!targetLayerId || !state || !selectedLayer) {
+ return;
+ }
+ const previousSelection = model.selected;
+ const segmentId = Object.keys(previousSelection ?? {}).find(
+ key => model.getLayer(key)?.type === 'StorySegmentLayer',
+ );
+
+ // Temporarily set the selected layer to the target layer
+ model.syncSelected({ [targetLayerId]: { type: 'layer' } });
+ await new Promise(resolve => setTimeout(resolve, SELECTION_SETTLE_MS));
+
+ const dialog = new SymbologyWidget({
+ model,
+ state,
+ isStorySegmentOverride: true,
+ segmentId,
+ });
+ await dialog.launch();
+
+ model.syncSelected(previousSelection ?? {});
+ };
+
+ return (
+
+
{item.children}
+
+
+ {item.hasRemove && (
+
+ )}
+
+
+ );
+}
+
+export function ArrayFieldTemplate(props: ArrayFieldTemplateProps) {
+ return (
+ <>
+ {props.title}
+
+ {props.items.map(item => (
+
+ ))}
+ {props.canAdd && (
+
+ )}
+
+ >
+ );
+}
diff --git a/packages/base/src/formbuilder/objectform/layer/storySegmentLayerForm.ts b/packages/base/src/formbuilder/objectform/layer/storySegmentLayerForm.ts
index 961269e54..456e18e38 100644
--- a/packages/base/src/formbuilder/objectform/layer/storySegmentLayerForm.ts
+++ b/packages/base/src/formbuilder/objectform/layer/storySegmentLayerForm.ts
@@ -1,13 +1,14 @@
-import { IDict } from '@jupytergis/schema';
+import { IDict, IStorySegmentLayer } from '@jupytergis/schema';
import { FieldProps } from '@rjsf/core';
import * as React from 'react';
import { LayerPropertiesForm } from './layerform';
+import { ArrayFieldTemplate } from '../components/SegmentFormSymbology';
import StorySegmentReset from '../components/StorySegmentReset';
export class StorySegmentLayerPropertiesForm extends LayerPropertiesForm {
protected processSchema(
- data: IDict | undefined,
+ data: IStorySegmentLayer | undefined,
schema: IDict,
uiSchema: IDict,
) {
@@ -49,6 +50,31 @@ export class StorySegmentLayerPropertiesForm extends LayerPropertiesForm {
},
};
+ uiSchema['layerOverride'] = {
+ ...uiSchema['layerOverride'],
+ items: {
+ 'ui:title': '',
+ targetLayer: {
+ 'ui:field': 'layerSelect',
+ },
+ opacity: {
+ 'ui:field': 'opacity',
+ },
+ },
+ 'ui:options': {
+ orderable: false,
+ },
+ 'ui:ArrayFieldTemplate': ArrayFieldTemplate,
+ };
+
+ // Remove properties that should not be displayed in the form
+ const layerOverrideItems =
+ schema.properties?.layerOverride?.items?.properties;
+ if (layerOverrideItems) {
+ delete layerOverrideItems.color;
+ delete layerOverrideItems.symbologyState;
+ }
+
this.removeFormEntry('zoom', data, schema, uiSchema);
}
}
diff --git a/packages/base/src/mainview/mainView.tsx b/packages/base/src/mainview/mainView.tsx
index 4b9acd37c..7aaa7f5df 100644
--- a/packages/base/src/mainview/mainView.tsx
+++ b/packages/base/src/mainview/mainView.tsx
@@ -1105,6 +1105,7 @@ export class MainView extends React.Component {
newMapLayer = new VectorTileLayer({
opacity: layerParameters.opacity,
+ visible: layer.visible,
source: this._sources[layerParameters.source],
style: this.vectorLayerStyleRuleBuilder(layer),
});
@@ -1116,6 +1117,7 @@ export class MainView extends React.Component {
newMapLayer = new WebGlTileLayer({
opacity: 0.3,
+ visible: layer.visible,
source: this._sources[layerParameters.source],
style: {
color: ['color', this.hillshadeMath()],
@@ -1129,6 +1131,7 @@ export class MainView extends React.Component {
newMapLayer = new ImageLayer({
opacity: layerParameters.opacity,
+ visible: layer.visible,
source: this._sources[layerParameters.source],
});
@@ -1140,6 +1143,7 @@ export class MainView extends React.Component {
// This is to handle python sending a None for the color
const layerOptions: any = {
opacity: layerParameters.opacity,
+ visible: layer.visible,
source: this._sources[layerParameters.source],
};
@@ -1157,6 +1161,7 @@ export class MainView extends React.Component {
newMapLayer = new HeatmapLayer({
opacity: layerParameters.opacity,
+ visible: layer.visible,
source: this._sources[layerParameters.source],
blur: layerParameters.blur ?? 15,
radius: layerParameters.radius ?? 8,
@@ -2788,6 +2793,8 @@ export class MainView extends React.Component {
commands={this._mainViewModel.commands}
formSchemaRegistry={this._formSchemaRegistry}
annotationModel={this._annotationModel}
+ addLayer={this.addLayer.bind(this)}
+ removeLayer={this.removeLayer.bind(this)}
settings={this.state.jgisSettings}
/>
)}
diff --git a/packages/base/src/panelview/rightpanel.tsx b/packages/base/src/panelview/rightpanel.tsx
index c7afe18ca..4c0903c53 100644
--- a/packages/base/src/panelview/rightpanel.tsx
+++ b/packages/base/src/panelview/rightpanel.tsx
@@ -1,6 +1,7 @@
import {
IAnnotationModel,
IJGISFormSchemaRegistry,
+ IJGISLayer,
IJupyterGISClientState,
IJupyterGISModel,
IJupyterGISSettings,
@@ -27,12 +28,16 @@ interface IRightPanelProps {
model: IJupyterGISModel;
commands: CommandRegistry;
settings: IJupyterGISSettings;
+ addLayer?: (id: string, layer: IJGISLayer, index: number) => Promise;
+ removeLayer?: (id: string) => void;
}
export const RightPanel: React.FC = props => {
const [editorMode, setEditorMode] = React.useState(true);
const [storyMapPresentationMode, setStoryMapPresentationMode] =
React.useState(props.model.getOptions().storyMapPresentationMode ?? false);
+ const [selectedObjectProperties, setSelectedObjectProperties] =
+ React.useState(undefined);
// Only show editor when not in presentation mode and editorMode is true
const showEditor = !storyMapPresentationMode && editorMode;
@@ -110,9 +115,6 @@ export const RightPanel: React.FC = props => {
const rightPanelVisible =
!props.settings.rightPanelDisabled && !allRightTabsDisabled;
- const [selectedObjectProperties, setSelectedObjectProperties] =
- React.useState(undefined);
-
const toggleEditor = () => {
setEditorMode(!editorMode);
};
@@ -172,7 +174,12 @@ export const RightPanel: React.FC = props => {
{showEditor ? (
) : (
-
+
)}
)}
diff --git a/packages/base/src/panelview/story-maps/StoryViewerPanel.tsx b/packages/base/src/panelview/story-maps/StoryViewerPanel.tsx
index 3b5ebf986..a00e511f8 100644
--- a/packages/base/src/panelview/story-maps/StoryViewerPanel.tsx
+++ b/packages/base/src/panelview/story-maps/StoryViewerPanel.tsx
@@ -2,7 +2,9 @@ import {
IJGISLayer,
IJGISStoryMap,
IJupyterGISModel,
+ IStorySegmentLayer,
} from '@jupytergis/schema';
+import { UUID } from '@lumino/coreutils';
import React, {
forwardRef,
useCallback,
@@ -20,11 +22,19 @@ import StoryImageSection from './components/StoryImageSection';
import StorySubtitleSection from './components/StorySubtitleSection';
import StoryTitleSection from './components/StoryTitleSection';
+/** Entry for a layer affected by symbology override: remove (added clone) or restore (modified existing). */
+interface IOverrideLayerEntry {
+ layerId: string;
+ action: 'remove' | 'restore';
+}
+
interface IStoryViewerPanelProps {
model: IJupyterGISModel;
isSpecta: boolean;
isMobile?: boolean;
className?: string;
+ addLayer?: (id: string, layer: IJGISLayer, index: number) => Promise;
+ removeLayer?: (id: string) => void;
}
export interface IStoryViewerPanelHandle {
@@ -67,288 +77,422 @@ function getStoryNavPlacement(
const StoryViewerPanel = forwardRef<
IStoryViewerPanelHandle,
IStoryViewerPanelProps
->(({ model, isSpecta, isMobile = false, className }, ref) => {
- const [currentIndexDisplayed, setCurrentIndexDisplayed] = useState(() =>
- model.getCurrentSegmentIndex(),
- );
- const [storyData, setStoryData] = useState(
- model.getSelectedStory().story ?? null,
- );
- const [imageLoaded, setImageLoaded] = useState(false);
- const panelRef = useRef(null);
-
- const setIndex = useCallback(
- (index: number) => {
- model.setCurrentSegmentIndex(index);
- setCurrentIndexDisplayed(index);
- },
- [model],
- );
-
- // Derive story segments from story data
- const storySegments = useMemo(() => {
- if (!storyData?.storySegments) {
- return [];
- }
+>(
+ (
+ { model, isSpecta, isMobile = false, className, addLayer, removeLayer },
+ ref,
+ ) => {
+ const [currentIndexDisplayed, setCurrentIndexDisplayed] = useState(() =>
+ model.getCurrentSegmentIndex(),
+ );
+ const [storyData, setStoryData] = useState(
+ model.getSelectedStory().story ?? null,
+ );
+ const [imageLoaded, setImageLoaded] = useState(false);
+ const panelRef = useRef(null);
+
+ const setIndex = useCallback(
+ (index: number) => {
+ model.setCurrentSegmentIndex(index);
+ setCurrentIndexDisplayed(index);
+ },
+ [model],
+ );
- return storyData.storySegments
- .map(storySegmentId => model.getLayer(storySegmentId))
- .filter((layer): layer is IJGISLayer => layer !== undefined);
- }, [storyData, model]);
-
- // Derive current story segment from story segments and currentIndexDisplayed
- const currentStorySegment = useMemo(() => {
- return storySegments[currentIndexDisplayed];
- }, [storySegments, currentIndexDisplayed]);
-
- // Derive active slide and layer name from current story segment
- const activeSlide = useMemo(() => {
- return currentStorySegment?.parameters;
- }, [currentStorySegment]);
-
- const layerName = useMemo(() => {
- return currentStorySegment?.name ?? '';
- }, [currentStorySegment]);
-
- // Derive story segment ID for zooming
- const currentStorySegmentId = useMemo(() => {
- return storyData?.storySegments?.[currentIndexDisplayed];
- }, [storyData, currentIndexDisplayed]);
-
- const zoomToCurrentLayer = () => {
- if (currentStorySegmentId) {
- model.centerOnPosition(currentStorySegmentId);
- }
- };
-
- const setSelectedLayerByIndex = useCallback(
- (index: number) => {
- const storySegmentId = storyData?.storySegments?.[index];
- if (storySegmentId) {
- model.selected = {
- [storySegmentId]: {
- type: 'layer',
- },
- };
+ /** Layers affected by symbology override
+ * We want to remove added layers (ie Heatmap)
+ * and Restore the original symbology for modified layers
+ */
+ const overrideLayerEntriesRef = useRef([]);
+
+ const clearOverrideLayers = useCallback(() => {
+ overrideLayerEntriesRef.current.forEach(({ layerId, action }) => {
+ if (action === 'remove') {
+ removeLayer?.(layerId);
+ } else {
+ const layer = model.getLayer(layerId);
+ if (layer) {
+ model.triggerLayerUpdate(layerId, layer);
+ }
+ }
+ });
+ overrideLayerEntriesRef.current = [];
+ }, [model]);
+
+ // Derive story segments from story data
+ const storySegments = useMemo(() => {
+ if (!storyData?.storySegments) {
+ return [];
}
- },
- [storyData, model],
- );
-
- useEffect(() => {
- const updateStory = () => {
- const { story } = model.getSelectedStory();
- setStoryData(story ?? null);
- // Reset to first slide when story changes
- setIndex(model.getCurrentSegmentIndex() ?? 0);
- };
-
- updateStory();
- model.sharedModel.storyMapsChanged.connect(updateStory);
+ return storyData.storySegments
+ .map(storySegmentId => model.getLayer(storySegmentId))
+ .filter((layer): layer is IJGISLayer => layer !== undefined);
+ }, [storyData, model]);
- return () => {
- model.sharedModel.storyMapsChanged.disconnect(updateStory);
- };
- }, [model, setIndex]);
+ // Derive current story segment from story segments and currentIndexDisplayed
+ const currentStorySegment = useMemo(() => {
+ return storySegments[currentIndexDisplayed];
+ }, [storySegments, currentIndexDisplayed]);
- // Prefetch image when slide changes
- useEffect(() => {
- const imageUrl = activeSlide?.content?.image;
+ // Derive active slide and layer name from current story segment
+ const activeSlide = useMemo(() => {
+ return currentStorySegment?.parameters;
+ }, [currentStorySegment]);
- if (!imageUrl) {
- setImageLoaded(false);
- return;
- }
+ const layerName = useMemo(
+ () => currentStorySegment?.name ?? '',
+ [currentStorySegment],
+ );
- // Reset state
- setImageLoaded(false);
+ // Derive story segment ID for zooming
+ const currentStorySegmentId = useMemo(() => {
+ return storyData?.storySegments?.[currentIndexDisplayed];
+ }, [storyData, currentIndexDisplayed]);
- // Preload the image
- const img = new Image();
+ const hasPrev = currentIndexDisplayed > 0;
+ const hasNext = currentIndexDisplayed < storySegments.length - 1;
- img.onload = () => {
- setImageLoaded(true);
+ const zoomToCurrentLayer = () => {
+ if (currentStorySegmentId) {
+ model.centerOnPosition(currentStorySegmentId);
+ }
};
- img.onerror = () => {
+ const setSelectedLayerByIndex = useCallback(
+ (index: number) => {
+ const storySegmentId = storyData?.storySegments?.[index];
+ if (storySegmentId) {
+ model.selected = {
+ [storySegmentId]: {
+ type: 'layer',
+ },
+ };
+ }
+ },
+ [storyData, model],
+ );
+
+ // On unmount: remove override layers and restore layer symbology
+ useEffect(() => {
+ return () => {
+ clearOverrideLayers();
+ storyData?.storySegments?.forEach(segmentId => {
+ const segment = model.getLayer(segmentId);
+ const overrides = segment?.parameters?.layerOverride;
+ if (Array.isArray(overrides)) {
+ overrides.forEach((override: any) => {
+ const targetLayerId = override.targetLayer;
+ const targetLayer = model.getLayer(targetLayerId);
+ targetLayer &&
+ model.triggerLayerUpdate(targetLayerId, targetLayer);
+ });
+ }
+ });
+ };
+ }, [storyData, model, clearOverrideLayers]);
+
+ useEffect(() => {
+ const updateStory = () => {
+ clearOverrideLayers();
+ const { story } = model.getSelectedStory();
+ setStoryData(story ?? null);
+ setIndex(model.getCurrentSegmentIndex() ?? 0);
+ };
+
+ updateStory();
+
+ model.sharedModel.storyMapsChanged.connect(updateStory);
+
+ return () => {
+ model.sharedModel.storyMapsChanged.disconnect(updateStory);
+ };
+ }, [model, setIndex, clearOverrideLayers]);
+
+ // Prefetch image when slide changes
+ useEffect(() => {
+ const imageUrl = activeSlide?.content?.image;
+
+ if (!imageUrl) {
+ setImageLoaded(false);
+ return;
+ }
+
+ // Reset state
setImageLoaded(false);
- };
- img.src = imageUrl;
+ // Preload the image
+ const img = new Image();
- // Cleanup: abort loading if component unmounts or slide changes
- return () => {
- img.onload = null;
- img.onerror = null;
- };
- }, [activeSlide?.content?.image]);
+ img.onload = () => {
+ setImageLoaded(true);
+ };
- // Auto-zoom when slide changes
- useEffect(() => {
- if (currentStorySegmentId) {
- zoomToCurrentLayer();
- }
- }, [currentStorySegmentId, model]);
+ img.onerror = () => {
+ setImageLoaded(false);
+ };
- // Set selected layer on initial render and when story data changes
- useEffect(() => {
- if (storyData?.storySegments && currentIndexDisplayed >= 0) {
- setSelectedLayerByIndex(currentIndexDisplayed);
- }
- }, [storyData, currentIndexDisplayed, setSelectedLayerByIndex]);
-
- // Listen for layer selection changes in unguided mode
- useEffect(() => {
- // ! TODO this logic (getting a single selected layer) is also in the processing index.ts, move to tools
- const handleSelectedStorySegmentChange = () => {
- // This is just to update the displayed content
- // So bail early if we don't need to do that
- if (!storyData || storyData.storyType !== 'unguided') {
- return;
- }
+ img.src = imageUrl;
- const localState = model.sharedModel.awareness.getLocalState();
- if (!localState || !localState['selected']?.value) {
- return;
- }
+ // Cleanup: abort loading if component unmounts or slide changes
+ return () => {
+ img.onload = null;
+ img.onerror = null;
+ };
+ }, [activeSlide?.content?.image]);
- const selectedLayers = Object.keys(localState['selected'].value);
+ // Auto-zoom when slide changes
+ useEffect(() => {
+ if (currentStorySegmentId) {
+ zoomToCurrentLayer();
+ }
+ }, [currentStorySegmentId, model]);
- // Ensure only one layer is selected
- if (selectedLayers.length !== 1) {
+ // Set selected layer and apply symbology when segment changes; remove previous segment's override layers first.
+ useEffect(() => {
+ if (!storyData?.storySegments || currentIndexDisplayed < 0) {
return;
}
+ clearOverrideLayers();
+ setSelectedLayerByIndex(currentIndexDisplayed);
+ overrideSymbology(currentIndexDisplayed);
+ }, [
+ storyData,
+ currentIndexDisplayed,
+ setSelectedLayerByIndex,
+ clearOverrideLayers,
+ ]);
+
+ // Set selected layer on initial render and when story data changes
+ useEffect(() => {
+ if (storyData?.storySegments && currentIndexDisplayed >= 0) {
+ setSelectedLayerByIndex(currentIndexDisplayed);
+ }
+ }, [storyData, currentIndexDisplayed, setSelectedLayerByIndex]);
+
+ // Listen for layer selection changes in unguided mode
+ useEffect(() => {
+ // ! TODO this logic (getting a single selected layer) is also in the processing index.ts, move to tools
+ const handleSelectedStorySegmentChange = () => {
+ // This is just to update the displayed content
+ // So bail early if we don't need to do that
+ if (!storyData || storyData.storyType !== 'unguided') {
+ return;
+ }
+
+ const localState = model.sharedModel.awareness.getLocalState();
+ if (!localState || !localState['selected']?.value) {
+ return;
+ }
+
+ const selectedLayers = Object.keys(localState['selected'].value);
+
+ // Ensure only one layer is selected
+ if (selectedLayers.length !== 1) {
+ return;
+ }
+
+ const selectedLayerId = selectedLayers[0];
+ const selectedLayer = model.getLayer(selectedLayerId);
+ if (!selectedLayer || selectedLayer.type !== 'StorySegmentLayer') {
+ return;
+ }
+
+ const index = storyData.storySegments?.indexOf(selectedLayerId);
+ if (index === undefined || index === -1) {
+ return;
+ }
+
+ setIndex(index);
+ };
+
+ // ! TODO really only want to connect this un unguided mode
+ model.sharedModel.awareness.on(
+ 'change',
+ handleSelectedStorySegmentChange,
+ );
- const selectedLayerId = selectedLayers[0];
- const selectedLayer = model.getLayer(selectedLayerId);
- if (!selectedLayer || selectedLayer.type !== 'StorySegmentLayer') {
+ return () => {
+ model.sharedModel.awareness.off(
+ 'change',
+ handleSelectedStorySegmentChange,
+ );
+ };
+ }, [model, storyData, setIndex]);
+
+ // Apply symbology overrides for the segment at the given index
+ const overrideSymbology = (index: number) => {
+ if (index < 0 || !storySegments[index]) {
return;
}
- const index = storyData.storySegments?.indexOf(selectedLayerId);
- if (index === undefined || index === -1) {
+ const segment = storySegments[index];
+ const layerOverrides: IStorySegmentLayer['layerOverride'] =
+ segment.parameters?.layerOverride;
+
+ if (!Array.isArray(layerOverrides)) {
return;
}
- setIndex(index);
+ // Apply all symbology overrides for this segment
+ layerOverrides.forEach(override => {
+ const {
+ color,
+ opacity,
+ symbologyState,
+ targetLayer: targetLayerId,
+ visible,
+ } = override;
+
+ if (!targetLayerId) {
+ return;
+ }
+
+ overrideLayerEntriesRef.current.push({
+ layerId: targetLayerId,
+ action: 'restore',
+ });
+
+ const targetLayer = model.getLayer(targetLayerId);
+
+ if (targetLayer?.parameters) {
+ if (symbologyState !== undefined) {
+ targetLayer.parameters.symbologyState = symbologyState;
+ }
+ if (color !== undefined) {
+ targetLayer.parameters.color = color;
+ }
+ if (opacity !== undefined) {
+ targetLayer.parameters.opacity = opacity;
+ }
+ if (visible !== undefined) {
+ targetLayer.visible = visible;
+ }
+ // Heatmaps are actually a different layer, not just symbology
+ // so they need special handling
+ if (symbologyState?.renderType === 'Heatmap') {
+ targetLayer.type = 'HeatmapLayer';
+ if (addLayer) {
+ const newId = UUID.uuid4();
+ addLayer(newId, targetLayer, 100);
+ overrideLayerEntriesRef.current.push({
+ layerId: newId,
+ action: 'remove',
+ });
+ }
+ } else {
+ model.triggerLayerUpdate(targetLayerId, targetLayer);
+ }
+ }
+ });
};
- model.sharedModel.awareness.on('change', handleSelectedStorySegmentChange);
+ const handlePrev = useCallback(() => {
+ if (hasPrev) {
+ setIndex(currentIndexDisplayed - 1);
+ }
+ }, [currentIndexDisplayed, setIndex]);
- return () => {
- model.sharedModel.awareness.off(
- 'change',
- handleSelectedStorySegmentChange,
- );
- };
- }, [model, storyData, setIndex]);
+ const handleNext = useCallback(() => {
+ if (hasNext) {
+ setIndex(currentIndexDisplayed + 1);
+ }
+ }, [currentIndexDisplayed, storySegments.length, setIndex]);
- const handlePrev = useCallback(() => {
- if (currentIndexDisplayed > 0) {
- setIndex(currentIndexDisplayed - 1);
+ if (!storyData || storyData?.storySegments?.length === 0) {
+ return (
+
+
No Segments available. Add one using the Add Layer menu.
+
+ );
}
- }, [currentIndexDisplayed, setIndex]);
- const handleNext = useCallback(() => {
- if (currentIndexDisplayed < storySegments.length - 1) {
- setIndex(currentIndexDisplayed + 1);
- }
- }, [currentIndexDisplayed, storySegments.length, setIndex]);
+ const storyNavBarProps = {
+ onPrev: handlePrev,
+ onNext: handleNext,
+ hasPrev,
+ hasNext,
+ };
- // Expose methods via ref for parent component to use
- useImperativeHandle(
- ref,
- () => ({
- handlePrev,
- handleNext,
- canNavigate: isSpecta,
- }),
- [handlePrev, handleNext, storyData, isSpecta],
- );
-
- if (!storyData || storyData?.storySegments?.length === 0) {
- return (
-
-
No Segments available. Add one using the Add Layer menu.
-
+ // Expose methods via ref for parent component to use
+ useImperativeHandle(
+ ref,
+ () => ({
+ handlePrev,
+ handleNext,
+ canNavigate: isSpecta,
+ }),
+ [handlePrev, handleNext, storyData, isSpecta],
);
- }
- const navProps = {
- onPrev: handlePrev,
- onNext: handleNext,
- hasPrev: currentIndexDisplayed > 0,
- hasNext: currentIndexDisplayed < storySegments.length - 1,
- };
-
- const hasImage = !!(activeSlide?.content?.image && imageLoaded);
- const storyType = storyData.storyType ?? 'guided';
- const navPlacement = getStoryNavPlacement(
- isSpecta,
- hasImage,
- storyType,
- isMobile,
- );
-
- const navSlot =
- navPlacement !== null ? (
-
- ) : null;
-
- // Get transition time from current segment, default to 0.3s
- const transitionTime = activeSlide?.transition?.time ?? 0.3;
-
- return (
-
+ const hasImage = !!(activeSlide?.content?.image && imageLoaded);
+ const storyType = storyData.storyType ?? 'guided';
+ const navPlacement = getStoryNavPlacement(
+ isSpecta,
+ hasImage,
+ storyType,
+ isMobile,
+ );
+
+ const navSlot =
+ navPlacement !== null ? (
+
+ ) : null;
+
+ // Get transition time from current segment, default to 0.3s
+ const transitionTime = activeSlide?.transition?.time ?? 0.3;
+
+ return (
-
-
- );
-});
+ );
+ },
+);
StoryViewerPanel.displayName = 'StoryViewerPanel';
diff --git a/packages/base/src/shared/hooks/useLatest.ts b/packages/base/src/shared/hooks/useLatest.ts
new file mode 100644
index 000000000..45a08c272
--- /dev/null
+++ b/packages/base/src/shared/hooks/useLatest.ts
@@ -0,0 +1,11 @@
+import { useEffect, useRef } from 'react';
+
+export function useLatest
(value: T): React.MutableRefObject {
+ const ref = useRef(value);
+
+ useEffect(() => {
+ ref.current = value;
+ }, [value]);
+
+ return ref;
+}
diff --git a/packages/base/src/types.ts b/packages/base/src/types.ts
index 87c11d5b4..1d0d5e116 100644
--- a/packages/base/src/types.ts
+++ b/packages/base/src/types.ts
@@ -51,3 +51,10 @@ const classificationModes = [
] as const;
export type ClassificationMode = (typeof classificationModes)[number];
+
+export const SYMBOLOGY_VALID_LAYER_TYPES = [
+ 'VectorLayer',
+ 'VectorTileLayer',
+ 'WebGlLayer',
+ 'HeatmapLayer',
+];
diff --git a/packages/base/style/base.css b/packages/base/style/base.css
index 32d63a425..34802163c 100644
--- a/packages/base/style/base.css
+++ b/packages/base/style/base.css
@@ -68,6 +68,14 @@
flex-direction: column;
}
+.jGIS-symbology-override-item {
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ padding-bottom: 1rem;
+ border-bottom: solid 1px var(--jp-border-color0);
+}
+
.jp-gis-text-label {
margin: 0;
padding: 0;
diff --git a/packages/schema/src/schema/project/layers/storySegmentLayer.json b/packages/schema/src/schema/project/layers/storySegmentLayer.json
index 9fd206f2e..cc7816378 100644
--- a/packages/schema/src/schema/project/layers/storySegmentLayer.json
+++ b/packages/schema/src/schema/project/layers/storySegmentLayer.json
@@ -2,7 +2,6 @@
"type": "object",
"description": "StorySegmentLayer",
"title": "IStorySegmentLayer",
- "additionalProperties": false,
"required": ["zoom", "extent", "transition"],
"properties": {
"zoom": {
@@ -18,9 +17,11 @@
},
"content": {
"type": "object",
+ "title": "Segment Content",
"additionalProperties": false,
"properties": {
"title": {
+ "title": "Segment Title",
"type": "string"
},
"image": {
@@ -50,6 +51,47 @@
}
},
"required": ["type", "time"]
+ },
+ "layerOverride": {
+ "type": "array",
+ "title": "Symbology Override",
+ "description": "Symbology overrides to apply to target layers when this story segment is active",
+ "items": {
+ "type": "object",
+ "properties": {
+ "targetLayer": {
+ "type": "string",
+ "title": "Target Layer",
+ "description": "The name of the layer to apply a symbology override to when this story segment is active",
+ "default": ""
+ },
+ "visible": {
+ "type": "boolean",
+ "title": "Visibility",
+ "default": true,
+ "description": "Override the target layer visibility while this story segment is active"
+ },
+ "opacity": {
+ "type": "number",
+ "title": "Opacity",
+ "description": "Override the target layer opacity while this story segment is active",
+ "minimum": 0,
+ "maximum": 1,
+ "multipleOf": 0.1,
+ "default": 1.0
+ },
+ "symbologyState": {
+ "type": "object",
+ "description": "The symbology state override (renderType, value, method, etc.)",
+ "default": {}
+ },
+ "color": {
+ "type": "object",
+ "description": "The color/style override",
+ "default": {}
+ }
+ }
+ }
}
}
}
diff --git a/python/jupytergis_lab/style/base.css b/python/jupytergis_lab/style/base.css
index 9a8d65ae4..e1ae07c1d 100644
--- a/python/jupytergis_lab/style/base.css
+++ b/python/jupytergis_lab/style/base.css
@@ -569,12 +569,6 @@ div.jGIS-toolbar-widget > div.jp-Toolbar-item:last-child {
padding: 0;
}
-.jGIS-property-panel .jp-objectFieldWrapper,
-.jGIS-property-panel .jp-arrayFieldWrapper {
- padding-left: var(--jp-notebook-padding);
- border-left: solid 4px var(--jp-border-color3);
-}
-
.jGIS-property-panel .array-item {
border: none;
}