diff --git a/docs/user_guide/how-tos/story-maps.md b/docs/user_guide/how-tos/story-maps.md new file mode 100644 index 000000000..4326917dd --- /dev/null +++ b/docs/user_guide/how-tos/story-maps.md @@ -0,0 +1,72 @@ +(how-to-story-map)= + +# Create and Edit Story Maps + + + +Story maps let you present a sequence of map views (segments) with optional text and images. This guide explains how to create a story, edit segments, use the Story Editor, and preview the story. + +## Creating a story + +A story is created when you add the first **Story Segment** layer: + +1. Open the **+** menu from the toolbar. +2. Add **Story Segment**. The new layer captures the current map view (zoom and extent) and becomes the first segment of a new story map. +3. The right panel will show the **Story Editor** so you can set story-level options and add more segments. + +If no story exists yet, the Story Editor panel shows an **Add Story Segment** button that does the same thing as the toolbar option. + +## Story Editor + +With the **Story Editor** tab selected in the right panel, you can: + +- **Edit story-level properties**: Title, Story Type (guided or unguided), Presentation Background Color, and Presentation Text Color. + +:::{admonition} Presentation Colors +:class: attention +The presentation colors are used only in the Specta view. +::: + +:::{admonition} Story Types +:class: tip + +- **Guided** stories advance with previous/next controls; +- **unguided** stories follow the selected segment in the layer list, so the viewer can jump to any segment by selecting it. + ::: + +- **Add segments**: Click **Add Story Segment** at the bottom of the panel. Each new segment again captures the current map view and is appended to the story. You can then select it in the layer list and edit its properties (see below). + +The Story Editor does not list or reorder segments directly; segment order follows the order of Story Segment layers in the **Segments** tab of the left panel. + +## Adding segments + +- **From the Story Editor**: Click **Add Story Segment** in the right panel. Position the map as desired before clicking; the new segment will use the current view. +- **From the Add Layer menu**: Add **Story Segment** as when creating the first segment. The new segment is added to the current story and uses the current map view. + +After adding a segment, select it in the **Segments** tab to edit its properties in the **Object Properties** panel. + +## Editing segment properties + +Select a Story Segment layer in the left panel (under the **Segments** tab). The **Object Properties** panel shows that segment’s form: + +- **Extent**: Use **Set Story Segment Extent** to snap the segment’s view to the current map extent and zoom. Helpful after panning/zooming to the area you want for that slide. +- **Segment Content**: Title, optional image URL, and markdown text for the narrative shown when that segment is active. +- **Transition**: Animation style and duration (in seconds) when moving to this segment. + - **Immediate** jumps there with no animation. + - **Linear** animates directly to the segment’s view. + - **Smooth** zooms out, pans to the segment, then zooms back in. +- **Symbology Override**: Optional overrides for other layers when this segment is active (e.g. visibility, opacity, or opening the symbology dialog for a target layer to set style). Add an override by choosing a target layer and configuring the options. + +Changes are saved as you edit; no separate “Save” step is required. + +## Preview toggle + +At the top of the Story panel (right panel, when the Story Editor tab is active) there is a **Preview Mode** switch. + +- **Preview Mode off** (default): The panel shows the **Story Editor** (story-level form and **Add Story Segment**). Use this to create and edit the story and segment properties. +- **Preview Mode on**: The panel shows the **Story Map** viewer: the same step-through experience as in full presentation mode (previous/next, segment content, map updates), but still inside the main JupyterGIS window. + +Use Preview Mode to check how the story will look and behave without entering full-screen presentation. The switch is hidden when you are already in presentation mode. diff --git a/examples/story_map.jGIS b/examples/story_map.jGIS index 6e874da4c..0de78622a 100644 --- a/examples/story_map.jGIS +++ b/examples/story_map.jGIS @@ -51,6 +51,7 @@ -4804544.428248574, -2622570.615984798 ], + "layerOverride": [], "transition": { "time": 2.0, "type": "smooth" @@ -74,6 +75,7 @@ -8240146.493537257, 4967973.433151666 ], + "layerOverride": [], "transition": { "time": 2.0, "type": "smooth" @@ -121,6 +123,7 @@ 261009.190947415, 6252093.017728101 ], + "layerOverride": [], "transition": { "time": 2.0, "type": "smooth" diff --git a/packages/base/src/commands/index.ts b/packages/base/src/commands/index.ts index 706d320da..ad4690e72 100644 --- a/packages/base/src/commands/index.ts +++ b/packages/base/src/commands/index.ts @@ -30,7 +30,7 @@ import keybindings from '../keybindings.json'; import { getSingleSelectedLayer } from '../processing/index'; import { addProcessingCommands } from '../processing/processingCommands'; import { getGeoJSONDataFromLayerSource, downloadFile } from '../tools'; -import { JupyterGISTracker } from '../types'; +import { JupyterGISTracker, SYMBOLOGY_VALID_LAYER_TYPES } from '../types'; import { JupyterGISDocumentWidget } from '../widget'; const POINT_SELECTION_TOOL_CLASS = 'jGIS-point-selection-tool'; @@ -131,12 +131,7 @@ export function addCommands( return false; } - const isValidLayer = [ - 'VectorLayer', - 'VectorTileLayer', - 'WebGlLayer', - 'HeatmapLayer', - ].includes(layer.type); + const isValidLayer = SYMBOLOGY_VALID_LAYER_TYPES.includes(layer.type); return isValidLayer; }, diff --git a/packages/base/src/dialogs/symbology/hooks/useEffectiveSymbologyParams.ts b/packages/base/src/dialogs/symbology/hooks/useEffectiveSymbologyParams.ts new file mode 100644 index 000000000..8f64932c6 --- /dev/null +++ b/packages/base/src/dialogs/symbology/hooks/useEffectiveSymbologyParams.ts @@ -0,0 +1,56 @@ +import { IJGISLayer, IJupyterGISModel } from '@jupytergis/schema'; +import { useMemo, useRef } from 'react'; + +import { + getEffectiveSymbologyParams, + type IEffectiveSymbologyParams, +} from '../symbologyUtils'; + +export interface IUseEffectiveSymbologyParamsArgs { + model: IJupyterGISModel; + layerId: string | undefined; + layer: IJGISLayer | null | undefined; + isStorySegmentOverride?: boolean; + segmentId?: string; +} + +/** + * Resolve the effective symbology params (layer.parameters or segment override) + * for the current dialog context. Pass a type parameter to narrow the return + * type for the layer kind this component uses (e.g. VectorSymbologyParams for + * vector symbology components). + */ +export function useEffectiveSymbologyParams< + T extends IEffectiveSymbologyParams = IEffectiveSymbologyParams, +>({ + model, + layerId, + layer, + isStorySegmentOverride, + segmentId, +}: IUseEffectiveSymbologyParamsArgs): T | null { + const result = useMemo(() => { + if (!layerId || !layer) { + return null; + } + return getEffectiveSymbologyParams( + model, + layerId, + layer, + isStorySegmentOverride, + segmentId, + ); + }, [model, layerId, layer, isStorySegmentOverride, segmentId]); + + // Stabilize reference + const prevRef = useRef<{ + value: IEffectiveSymbologyParams | null; + serialized: string; + }>({ value: null, serialized: '' }); + const serialized = result === null ? '' : JSON.stringify(result); + if (serialized === prevRef.current.serialized) { + return prevRef.current.value as T | null; + } + prevRef.current = { value: result, serialized }; + return result as T | null; +} diff --git a/packages/base/src/dialogs/symbology/hooks/useOkSignal.ts b/packages/base/src/dialogs/symbology/hooks/useOkSignal.ts new file mode 100644 index 000000000..78265bbb1 --- /dev/null +++ b/packages/base/src/dialogs/symbology/hooks/useOkSignal.ts @@ -0,0 +1,39 @@ +import { Signal } from '@lumino/signaling'; +import { useCallback, useEffect, useRef } from 'react'; + +type OkSignalPromise = { + promise: Promise>; +}; + +export function useOkSignal( + okSignalPromise: OkSignalPromise, + handleOk: () => void, +): void { + const handleOkRef = useRef(handleOk); + + useEffect(() => { + handleOkRef.current = handleOk; + }, [handleOk]); + + const slot = useCallback(() => { + handleOkRef.current(); + }, []); + + useEffect(() => { + let disposed = false; + + okSignalPromise.promise.then(okSignal => { + if (disposed) { + return; + } + okSignal.connect(slot); + }); + + return () => { + disposed = true; + okSignalPromise.promise.then(okSignal => { + okSignal.disconnect(slot); + }); + }; + }, [okSignalPromise, slot]); +} diff --git a/packages/base/src/dialogs/symbology/symbologyDialog.tsx b/packages/base/src/dialogs/symbology/symbologyDialog.tsx index ba49e1c5f..ca03885cb 100644 --- a/packages/base/src/dialogs/symbology/symbologyDialog.tsx +++ b/packages/base/src/dialogs/symbology/symbologyDialog.tsx @@ -11,10 +11,10 @@ import VectorRendering from './vector_layer/VectorRendering'; export interface ISymbologyDialogProps { model: IJupyterGISModel; - state: IStateDB; okSignalPromise: PromiseDelegate>; - cancel: () => void; layerId?: string; + isStorySegmentOverride?: boolean; + segmentId?: string; } export interface ISymbologyDialogWithAttributesProps extends ISymbologyDialogProps { @@ -31,6 +31,8 @@ export type ISymbologyTabbedDialogWithAttributesProps = export interface ISymbologyWidgetOptions { model: IJupyterGISModel; state: IStateDB; + isStorySegmentOverride?: boolean; + segmentId?: string; } export interface IStopRow { @@ -40,9 +42,9 @@ export interface IStopRow { const SymbologyDialog: React.FC = ({ model, - state, okSignalPromise, - cancel, + isStorySegmentOverride, + segmentId, }) => { const [selectedLayer, setSelectedLayer] = useState(null); const [componentToRender, setComponentToRender] = @@ -90,10 +92,10 @@ const SymbologyDialog: React.FC = ({ LayerSymbology = ( ); break; @@ -101,10 +103,10 @@ const SymbologyDialog: React.FC = ({ LayerSymbology = ( ); break; @@ -121,10 +123,6 @@ export class SymbologyWidget extends Dialog { private okSignal: Signal; constructor(options: ISymbologyWidgetOptions) { - const cancelCallback = () => { - this.resolve(0); - }; - const okSignalPromise = new PromiseDelegate< Signal >(); @@ -133,29 +131,28 @@ export class SymbologyWidget extends Dialog { ); super({ title: 'Symbology', body }); this.id = 'jupytergis::symbologyWidget'; - this.okSignal = new Signal(this); + okSignalPromise.resolve(this.okSignal); this.addClass('jp-gis-symbology-dialog'); } resolve(index: number): void { - if (index === 0) { - super.resolve(index); - } - if (index === 1) { + // Emit signal to let symbology components save this.okSignal.emit(null); } + + super.resolve(index); } } diff --git a/packages/base/src/dialogs/symbology/symbologyUtils.ts b/packages/base/src/dialogs/symbology/symbologyUtils.ts index 0dfa85809..1b1faf521 100644 --- a/packages/base/src/dialogs/symbology/symbologyUtils.ts +++ b/packages/base/src/dialogs/symbology/symbologyUtils.ts @@ -1,18 +1,160 @@ -import { IJGISLayer } from '@jupytergis/schema'; +import { + IJGISLayer, + IJupyterGISModel, + IVectorLayer, + IWebGlLayer, +} from '@jupytergis/schema'; import colormap from 'colormap'; import { ColorRampName } from './colorRampUtils'; import { IStopRow } from './symbologyDialog'; const COLOR_EXPR_STOPS_START = 3; + +/** Payload when saving symbology; shape matches vector or WebGl layer params. */ +export interface ISymbologyPayload { + symbologyState: + | IVectorLayer['symbologyState'] + | IWebGlLayer['symbologyState']; + color?: IVectorLayer['color'] | IWebGlLayer['color']; +} + +export interface ISaveSymbologyOptions { + model: IJupyterGISModel; + layerId: string; + isStorySegmentOverride?: boolean; + segmentId?: string; + payload: ISymbologyPayload; + mutateLayerBeforeSave?: (layer: any) => void; +} + +export type VectorSymbologyParams = Pick< + IVectorLayer, + 'symbologyState' | 'color' +>; + +export type WebGlSymbologyParams = Pick< + IWebGlLayer, + 'symbologyState' | 'color' +>; + +/** Params-shaped object used for reading symbology (layer.parameters or segment override). */ +export type IEffectiveSymbologyParams = + | VectorSymbologyParams + | WebGlSymbologyParams; + +/** + * Resolve the effective symbology params for this dialog: either the layer's + * parameters or the matching segment override when editing a story-segment override. + */ +export function getEffectiveSymbologyParams( + model: IJupyterGISModel, + layerId: string, + layer: IJGISLayer | null | undefined, + isStorySegmentOverride?: boolean, + segmentId?: string, +): IEffectiveSymbologyParams | null { + if (!layer?.parameters) { + return null; + } + if (!isStorySegmentOverride) { + return layer.parameters as IEffectiveSymbologyParams; + } + if (!segmentId) { + return null; + } + const segment = model.getLayer(segmentId); + const override = segment?.parameters?.layerOverride?.find( + (override: { targetLayer?: string }) => override.targetLayer === layerId, + ); + + if (!override.symbologyState) { + override.symbologyState = {}; + } + return (override as IEffectiveSymbologyParams) ?? null; +} + +export function saveSymbology(options: ISaveSymbologyOptions): void { + const { + model, + layerId, + isStorySegmentOverride, + segmentId, + payload, + mutateLayerBeforeSave, + } = options; + + if (!isStorySegmentOverride) { + const layer = model.getLayer(layerId); + if (!layer?.parameters) { + return; + } + + layer.parameters.symbologyState = payload.symbologyState; + if (payload.color !== undefined) { + layer.parameters.color = payload.color; + } + + mutateLayerBeforeSave?.(layer); + model.sharedModel.updateLayer(layerId, layer); + return; + } + + if (!segmentId) { + return; + } + + const segment = model.getLayer(segmentId); + if (!segment?.parameters) { + return; + } + + if (!segment.parameters.layerOverride) { + segment.parameters.layerOverride = []; + } + + // Find the override for the target layer (from the selected layer in the dialog) + const targetLayerId = model.selected + ? Object.keys(model.selected).find( + id => + id !== segmentId && model.getLayer(id)?.type !== 'StorySegmentLayer', + ) + : undefined; + + if (!targetLayerId) { + return; + } + + const overrides = segment.parameters.layerOverride; + let override = overrides.find( + (override: any) => override.targetLayer === targetLayerId, + ); + + if (!override) { + // Create new override entry + override = { + targetLayer: targetLayerId, + visible: true, + opacity: 1, + }; + overrides.push(override); + } + + override.symbologyState = payload.symbologyState; + if (payload.color !== undefined) { + override.color = payload.color; + } + + model.sharedModel.updateLayer(segmentId, segment); +} export namespace VectorUtils { - export const buildColorInfo = (layer: IJGISLayer) => { + export const buildColorInfo = (layerParamers: VectorSymbologyParams) => { // This it to parse a color object on the layer - if (!layer.parameters?.color) { + if (!layerParamers?.color) { return []; } - const color = layer.parameters.color; + const color = layerParamers.color; // If color is a string we don't need to parse if (typeof color === 'string') { diff --git a/packages/base/src/dialogs/symbology/tiff_layer/TiffRendering.tsx b/packages/base/src/dialogs/symbology/tiff_layer/TiffRendering.tsx index 3c9cefd23..79b7eed21 100644 --- a/packages/base/src/dialogs/symbology/tiff_layer/TiffRendering.tsx +++ b/packages/base/src/dialogs/symbology/tiff_layer/TiffRendering.tsx @@ -6,10 +6,10 @@ import SingleBandPseudoColor from './types/SingleBandPseudoColor'; const TiffRendering: React.FC = ({ model, - state, okSignalPromise, - cancel, layerId, + isStorySegmentOverride, + segmentId, }) => { const renderTypes = ['Singleband Pseudocolor', 'Multiband Color']; const [selectedRenderType, setSelectedRenderType] = useState(); @@ -36,10 +36,10 @@ const TiffRendering: React.FC = ({ RenderComponent = ( ); break; @@ -47,10 +47,10 @@ const TiffRendering: React.FC = ({ RenderComponent = ( ); break; diff --git a/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx b/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx index f5c36b439..5c7279517 100644 --- a/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx +++ b/packages/base/src/dialogs/symbology/tiff_layer/types/MultibandColor.tsx @@ -1,11 +1,18 @@ import { IWebGlLayer } from '@jupytergis/schema'; import { ExpressionValue } from 'ol/expr/expression'; -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useState } from 'react'; import useGetBandInfo from '@/src/dialogs/symbology/hooks/useGetBandInfo'; +import { useOkSignal } from '@/src/dialogs/symbology/hooks/useOkSignal'; import { ISymbologyDialogProps } from '@/src/dialogs/symbology/symbologyDialog'; +import { + saveSymbology, + 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 { useEffectiveSymbologyParams } from '../../hooks/useEffectiveSymbologyParams'; interface ISelectedBands { red: number; @@ -19,14 +26,24 @@ type rgbEnum = keyof ISelectedBands; const MultibandColor: 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; } @@ -39,38 +56,15 @@ const MultibandColor: React.FC = ({ alpha: 4, }); - const numOfBandsRef = useRef(0); - const selectedBandsRef = useRef({ - red: selectedBands.red, - green: selectedBands.green, - blue: selectedBands.blue, - alpha: selectedBands.alpha, - }); + const numOfBandsRef = useLatest(bandRows.length); + const selectedBandsRef = useLatest(selectedBands); useEffect(() => { populateOptions(); - - okSignalPromise.promise.then(okSignal => { - okSignal.connect(handleOk); - }); - - return () => { - okSignalPromise.promise.then(okSignal => { - okSignal.disconnect(handleOk, this); - }); - }; }, []); - useEffect(() => { - numOfBandsRef.current = bandRows.length; - }, [bandRows]); - - useEffect(() => { - selectedBandsRef.current = selectedBands; - }, [selectedBands]); - const populateOptions = async () => { - const layerParams = layer.parameters as IWebGlLayer; + const layerParams = params as IWebGlLayer; const red = layerParams.symbologyState?.redBand ?? 1; const green = layerParams.symbologyState?.greenBand ?? 2; const blue = layerParams.symbologyState?.blueBand ?? 3; @@ -87,11 +81,6 @@ const MultibandColor: React.FC = ({ }; const handleOk = () => { - // Update layer - if (!layer.parameters) { - return; - } - const colorExpr: ExpressionValue[] = ['array']; const colors: (keyof ISelectedBands)[] = ['red', 'green', 'blue']; @@ -115,14 +104,23 @@ const MultibandColor: React.FC = ({ alphaBand: selectedBandsRef.current.alpha, }; - layer.parameters.symbologyState = symbologyState; - layer.parameters.color = colorExpr; - layer.type = 'WebGlLayer'; - - model.sharedModel.updateLayer(layerId, layer); - cancel(); + saveSymbology({ + model, + layerId, + isStorySegmentOverride, + segmentId, + payload: { + symbologyState, + color: colorExpr, + }, + mutateLayerBeforeSave: targetLayer => { + targetLayer.type = 'WebGlLayer'; + }, + }); }; + useOkSignal(okSignalPromise, handleOk); + return (
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 (
-
-

- {layerName ?? `Slide ${currentIndexDisplayed + 1}`} -

- {activeSlide?.content?.image && imageLoaded ? ( - +
+

+ {layerName ?? `Slide ${currentIndexDisplayed + 1}`} +

+ {activeSlide?.content?.image && imageLoaded ? ( + + ) : ( + + )} + - ) : ( - +
+ - )} - -
-
- +
-
- ); -}); + ); + }, +); 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; }