diff --git a/packages/base/src/dialogs/symbology/hooks/useGetSymbology.ts b/packages/base/src/dialogs/symbology/hooks/useGetSymbology.ts new file mode 100644 index 000000000..a127fcf6e --- /dev/null +++ b/packages/base/src/dialogs/symbology/hooks/useGetSymbology.ts @@ -0,0 +1,83 @@ +import { IJupyterGISModel } from '@jupytergis/schema'; +import { useEffect, useState } from 'react'; + +interface IUseGetSymbologyProps { + layerId?: string; + model: IJupyterGISModel; +} + +interface IUseGetSymbologyResult { + symbology: Record | null; + isLoading: boolean; + error?: Error; +} + +/** + * Extracts symbology information (paint/layout + symbologyState) + * for a given layer from the JupyterGIS model. + * Keeps symbology updated when the layer changes. + */ +export const useGetSymbology = ({ + layerId, + model, +}: IUseGetSymbologyProps): IUseGetSymbologyResult => { + const [symbology, setSymbology] = useState | null>(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(); + + useEffect(() => { + if (!layerId) { + return; + } + + let disposed = false; + + const fetchSymbology = () => { + try { + setIsLoading(true); + setError(undefined); + + const layer = model.getLayer(layerId); + + if (!layer) { + throw new Error(`Layer not found: ${layerId}`); + } + + const params = layer.parameters ?? {}; + const { symbologyState, color, ...rest } = params; + + const result: Record = { + ...rest, + ...(color ? { color } : {}), + ...(symbologyState ? { symbologyState } : {}), + }; + + if (!disposed) { + setSymbology(result); + } + } catch (err) { + if (!disposed) { + setError(err as Error); + setSymbology(null); + } + } finally { + if (!disposed) { + setIsLoading(false); + } + } + }; + + // initial load + fetchSymbology(); + + model.sharedModel.awareness.on('change', () => { + fetchSymbology(); + }); + + return () => { + disposed = true; + }; + }, [layerId, model]); + + return { symbology, isLoading, error }; +}; diff --git a/packages/base/src/panelview/components/layers.tsx b/packages/base/src/panelview/components/layers.tsx index 4014ae92f..7909dee65 100644 --- a/packages/base/src/panelview/components/layers.tsx +++ b/packages/base/src/panelview/components/layers.tsx @@ -8,7 +8,12 @@ import { } from '@jupytergis/schema'; import { DOMUtils } from '@jupyterlab/apputils'; import { IStateDB } from '@jupyterlab/statedb'; -import { Button, LabIcon, caretDownIcon } from '@jupyterlab/ui-components'; +import { + Button, + LabIcon, + caretDownIcon, + caretRightIcon, +} from '@jupyterlab/ui-components'; import { CommandRegistry } from '@lumino/commands'; import { ReadonlyPartialJSONObject, UUID } from '@lumino/coreutils'; import React, { @@ -18,8 +23,10 @@ import React, { } from 'react'; import { CommandIDs, icons } from '@/src/constants'; +import { useGetSymbology } from '@/src/dialogs/symbology/hooks/useGetSymbology'; import { nonVisibilityIcon, visibilityIcon } from '@/src/icons'; import { ILeftPanelClickHandlerParams } from '@/src/panelview/leftpanel'; +import { LegendItem } from './legendItem'; const LAYER_GROUP_CLASS = 'jp-gis-layerGroup'; const LAYER_GROUP_HEADER_CLASS = 'jp-gis-layerGroupHeader'; @@ -394,6 +401,15 @@ const LayerComponent: React.FC = props => { // TODO Support multi-selection as `model?.jGISModel?.localState?.selected.value` does isSelected(layerId, gisModel), ); + const [expanded, setExpanded] = useState(false); + + const { symbology } = useGetSymbology({ + layerId, + model: gisModel as IJupyterGISModel, + }); + + const hasSupportedSymbology = symbology?.symbologyState !== undefined; + const name = layer.name; useEffect(() => { @@ -444,12 +460,32 @@ const LayerComponent: React.FC = props => { onDragOver={Private.onDragOver} onDragEnd={Private.onDragEnd} data-id={layerId} + style={{ display: 'flex', flexDirection: 'column' }} >
+ {/* Expand/collapse legend button (only if symbology is supported) */} + {hasSupportedSymbology && ( + + )} + + {/* Visibility toggle */}
+ + {/* Show legend only if supported symbology */} + {expanded && gisModel && hasSupportedSymbology && ( +
+ +
+ )} ); }; diff --git a/packages/base/src/panelview/components/legendItem.tsx b/packages/base/src/panelview/components/legendItem.tsx new file mode 100644 index 000000000..50fa2fbd2 --- /dev/null +++ b/packages/base/src/panelview/components/legendItem.tsx @@ -0,0 +1,243 @@ +import { IJupyterGISModel } from '@jupytergis/schema'; +import React, { useEffect, useState } from 'react'; + +import { useGetSymbology } from '@/src/dialogs/symbology/hooks/useGetSymbology'; + +export const LegendItem: React.FC<{ + layerId: string; + model: IJupyterGISModel; +}> = ({ layerId, model }) => { + const { symbology, isLoading, error } = useGetSymbology({ layerId, model }); + const [content, setContent] = useState(null); + + const parseColorStops = (expr: any): { value: number; color: string }[] => { + if (!Array.isArray(expr) || expr[0] !== 'interpolate') { + return []; + } + const stops: { value: number; color: string }[] = []; + for (let i = 3; i < expr.length; i += 2) { + const value = expr[i] as number; + const rgba = expr[i + 1] as [number, number, number, number] | string; + const color = Array.isArray(rgba) + ? `rgba(${rgba[0]},${rgba[1]},${rgba[2]},${rgba[3]})` + : String(rgba); + stops.push({ value, color }); + } + return stops; + }; + + const parseCaseCategories = ( + expr: any, + ): { category: string | number; color: string }[] => { + if (!Array.isArray(expr) || expr[0] !== 'case') { + return []; + } + const categories: { category: string | number; color: string }[] = []; + for (let i = 1; i < expr.length - 1; i += 2) { + const condition = expr[i]; + const colorExpr = expr[i + 1]; + let category: any = ''; + if (Array.isArray(condition) && condition[0] === '==') { + category = condition[2]; + } + let color = ''; + if (Array.isArray(colorExpr)) { + color = `rgba(${colorExpr[0]},${colorExpr[1]},${colorExpr[2]},${colorExpr[3]})`; + } else if (typeof colorExpr === 'string') { + color = colorExpr; + } + categories.push({ category, color }); + } + return categories; + }; + + useEffect(() => { + if (isLoading) { + setContent(

Loading…

); + return; + } + if (error) { + setContent( +

{error.message}

, + ); + return; + } + if (!symbology) { + setContent(

No symbology

); + return; + } + + const renderType = symbology.symbologyState?.renderType; + const property = symbology.symbologyState?.value; + const fill = + symbology.color?.['fill-color'] ?? symbology.color?.['circle-fill-color']; + const stroke = + symbology.color?.['stroke-color'] ?? + symbology.color?.['circle-stroke-color']; + + // Single Symbol + if (renderType === 'Single Symbol') { + setContent( +
+ {fill && ( +
+ + Fill +
+ )} + {stroke && ( +
+ + Stroke +
+ )} + {!fill && !stroke && ( + No symbol colors + )} +
, + ); + return; + } + + // Graduated + if (renderType === 'Graduated') { + const stops = parseColorStops(fill || stroke); + if (!stops.length) { + setContent(

No graduated symbology

); + return; + } + + const segments = stops + .map((s, i) => { + const pct = (i / (stops.length - 1)) * 100; + return `${s.color} ${pct}%`; + }) + .join(', '); + const gradient = `linear-gradient(to right, ${segments})`; + + setContent( +
+ {property && ( +
+ {property} +
+ )} +
+ {stops.map((s, i) => { + const left = (i / (stops.length - 1)) * 100; + const up = i % 2 === 0; + return ( +
+
+
+ {s.value.toFixed(2)} +
+
+ ); + })} +
+
, + ); + return; + } + + // Categorized + if (renderType === 'Categorized') { + const cats = parseCaseCategories(fill || stroke); + if (!cats.length) { + setContent( +

No categorized symbology

, + ); + return; + } + + setContent( +
+ {property && ( +
+ {property} +
+ )} +
+ {cats.map((c, i) => ( +
+ + {String(c.category)} +
+ ))} +
+
, + ); + return; + } + + setContent(

Unsupported symbology: {String(renderType)}

); + }, [symbology, isLoading, error]); + + return
{content}
; +}; diff --git a/packages/base/style/leftPanel.css b/packages/base/style/leftPanel.css index 4a5d4c5fe..6765a7ec6 100644 --- a/packages/base/style/leftPanel.css +++ b/packages/base/style/leftPanel.css @@ -41,7 +41,7 @@ .jp-gis-source { display: flex; flex-direction: row; - align-items: center; + /* align-items: center; */ color: var(--jp-ui-font-color1); }