diff --git a/examples/earthquakes.jGIS b/examples/earthquakes.jGIS index 226ec5a4a..18eff9fb2 100644 --- a/examples/earthquakes.jGIS +++ b/examples/earthquakes.jGIS @@ -1,117 +1,67 @@ { "layerTree": [ - "0959c04f-a841-4fa2-8b44-d262e89e4c9a", - "b116b76f-e040-4908-9098-a6fbea7ca5bc" + "8de7c2c0-6024-4716-b542-031a89fb87f9", + "3e21d680-406f-4099-bd9e-3a4edb9a2c8b" ], "layers": { - "0959c04f-a841-4fa2-8b44-d262e89e4c9a": { - "name": "OpenStreetMap.Mapnik Layer", - "parameters": { - "source": "a7ed9785-8797-4d6d-a6a9-062ce78ba7ba" + "3e21d680-406f-4099-bd9e-3a4edb9a2c8b": { + "filters": { + "appliedFilters": [], + "logicalOp": "all" }, - "type": "RasterLayer", - "visible": true - }, - "b116b76f-e040-4908-9098-a6fbea7ca5bc": { - "name": "earthquakes", + "name": "Custom GeoJSON Layer", "parameters": { "color": { - "circle-fill-color": [ - "case", - [ - "==", - [ - "get", - "tsunami" - ], - 0.0 - ], - [ - 125.0, - 0.0, - 179.0, - 1.0 - ], - [ - "==", - [ - "get", - "tsunami" - ], - 1.0 - ], - [ - 147.0, - 255.0, - 0.0, - 1.0 - ], - [ - 0.0, - 0.0, - 0.0, - 0.0 - ] - ], - "circle-radius": [ - "interpolate", - [ - "linear" - ], - [ - "get", - "mag" - ], - 1.0, - 1.0, - 2.0, - 2.0, - 3.0, - 3.0, - 4.0, - 4.0, - 5.0, - 5.0, - 6.0, - 6.0 - ], - "circle-stroke-color": "#3399CC", + "circle-fill-color": "#f66151", + "circle-radius": 5.0, + "circle-stroke-color": "#62a0ea", "circle-stroke-line-cap": "round", "circle-stroke-line-join": "round", "circle-stroke-width": 1.25 }, "opacity": 1.0, - "source": "dc048820-75cd-4b8d-a1fb-91642901cd82", + "source": "348d85fa-3a71-447f-8a64-e283ec47cc7c", "symbologyState": { - "colorRamp": "cool", - "mode": "", - "nClasses": "", - "renderType": "Categorized", - "value": "tsunami" + "renderType": "Single Symbol" }, "type": "circle" }, "type": "VectorLayer", "visible": true + }, + "8de7c2c0-6024-4716-b542-031a89fb87f9": { + "name": "OpenStreetMap.Mapnik Layer", + "parameters": { + "source": "b2ea427a-a51b-43ad-ae72-02cd900736d5" + }, + "type": "RasterLayer", + "visible": true } }, "metadata": {}, "options": { "bearing": 0.0, "extent": [ - -14291047.530673811, - -3536164.7121253638, - -9426274.876637986, - 9088825.15152122 + -14723872.80667293, + -4835119.4874388315, + -1931504.9042952778, + 13305887.78837996 ], - "latitude": 24.187972965810673, - "longitude": -106.52816608439294, + "latitude": 35.52446437432016, + "longitude": -74.80890180273175, "pitch": 0.0, "projection": "EPSG:3857", - "zoom": 3.8783091860507373 + "zoom": 2.6670105136699993 }, "sources": { - "a7ed9785-8797-4d6d-a6a9-062ce78ba7ba": { + "348d85fa-3a71-447f-8a64-e283ec47cc7c": { + "name": "Custom GeoJSON Layer Source", + "parameters": { + "path": "../../examples/eq.json" + }, + "type": "GeoJSONSource" + }, + "b2ea427a-a51b-43ad-ae72-02cd900736d5": { "name": "OpenStreetMap.Mapnik", "parameters": { "attribution": "(C) OpenStreetMap contributors", @@ -122,13 +72,6 @@ "urlParameters": {} }, "type": "RasterSource" - }, - "dc048820-75cd-4b8d-a1fb-91642901cd82": { - "name": "Custom GeoJSON Layer Source", - "parameters": { - "path": "eq.json" - }, - "type": "GeoJSONSource" } } } diff --git a/packages/base/package.json b/packages/base/package.json index ca4e11101..bdf28bab2 100644 --- a/packages/base/package.json +++ b/packages/base/package.json @@ -68,6 +68,7 @@ "ajv": "^8.14.0", "colormap": "^2.3.2", "d3-color": "^3.1.0", + "date-fns": "^4.1.0", "gdal3.js": "^2.8.1", "geojson-vt": "^4.0.2", "geotiff": "^2.1.3", diff --git a/packages/base/src/commands.ts b/packages/base/src/commands.ts index 244aec57c..94f076e3d 100644 --- a/packages/base/src/commands.ts +++ b/packages/base/src/commands.ts @@ -170,6 +170,55 @@ export function addCommands( ...icons.get(CommandIDs.identify) }); + commands.addCommand(CommandIDs.temporalController, { + label: trans.__('Temporal Controller'), + isToggled: () => { + return tracker.currentWidget?.model.isTemporalControllerActive || false; + }, + isEnabled: () => { + const model = tracker.currentWidget?.model; + if (!model) { + return false; + } + + const selectedLayers = model.localState?.selected?.value; + if (!selectedLayers) { + return false; + } + + const layerId = Object.keys(selectedLayers)[0]; + const layerType = model.getLayer(layerId)?.type; + if (!layerType) { + return false; + } + + // Selection should only be one vector or heatmap layer + const isSelectionValid = + Object.keys(selectedLayers).length === 1 && + !model.getSource(layerId) && + ['VectorLayer', 'HeatmapLayer'].includes(layerType); + + if (!isSelectionValid && model.isTemporalControllerActive) { + model.toggleTemporalController(); + commands.notifyCommandChanged(CommandIDs.temporalController); + + return false; + } + + return true; + }, + execute: () => { + const current = tracker.currentWidget; + if (!current) { + return; + } + + current.model.toggleTemporalController(); + commands.notifyCommandChanged(CommandIDs.temporalController); + }, + ...icons.get(CommandIDs.temporalController) + }); + /** * SOURCES and LAYERS creation commands. */ diff --git a/packages/base/src/constants.ts b/packages/base/src/constants.ts index 152e639da..d87009d0a 100644 --- a/packages/base/src/constants.ts +++ b/packages/base/src/constants.ts @@ -10,6 +10,7 @@ export namespace CommandIDs { export const undo = 'jupytergis:undo'; export const symbology = 'jupytergis:symbology'; export const identify = 'jupytergis:identify'; + export const temporalController = 'jupytergis:temporalController'; // Layers and sources creation commands export const openLayerBrowser = 'jupytergis:openLayerBrowser'; @@ -99,7 +100,8 @@ const iconObject = { [CommandIDs.newShapefileLayer]: { iconClass: 'fa fa-file' }, [CommandIDs.newGeoTiffEntry]: { iconClass: 'fa fa-image' }, [CommandIDs.symbology]: { iconClass: 'fa fa-brush' }, - [CommandIDs.identify]: { iconClass: 'fa fa-info' } + [CommandIDs.identify]: { iconClass: 'fa fa-info' }, + [CommandIDs.temporalController]: { iconClass: 'fa fa-clock' } }; /** diff --git a/packages/base/src/dialogs/symbology/hooks/useGetProperties.ts b/packages/base/src/dialogs/symbology/hooks/useGetProperties.ts index a7df44540..a7ea81e59 100644 --- a/packages/base/src/dialogs/symbology/hooks/useGetProperties.ts +++ b/packages/base/src/dialogs/symbology/hooks/useGetProperties.ts @@ -10,7 +10,7 @@ interface IUseGetPropertiesProps { } interface IUseGetPropertiesResult { - featureProps: Record>; + featureProperties: Record>; isLoading: boolean; error?: Error; } @@ -19,7 +19,7 @@ export const useGetProperties = ({ layerId, model }: IUseGetPropertiesProps): IUseGetPropertiesResult => { - const [featureProps, setFeatureProps] = useState({}); + const [featureProperties, setFeatureProperties] = useState({}); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(undefined); @@ -51,17 +51,15 @@ export const useGetProperties = ({ data.features.forEach((feature: GeoJSONFeature1) => { if (feature.properties) { Object.entries(feature.properties).forEach(([key, value]) => { - if (typeof value !== 'string') { - if (!(key in result)) { - result[key] = new Set(); - } - result[key].add(value); + if (!(key in result)) { + result[key] = new Set(); } + result[key].add(value); }); } }); - setFeatureProps(result); + setFeatureProperties(result); setIsLoading(false); } catch (err) { setError(err as Error); @@ -73,5 +71,5 @@ export const useGetProperties = ({ getProperties(); }, [model, layerId]); - return { featureProps, isLoading, error }; + return { featureProperties, isLoading, error }; }; 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 b0aea7544..f981358c0 100644 --- a/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx +++ b/packages/base/src/dialogs/symbology/vector_layer/types/Categorized.tsx @@ -2,6 +2,7 @@ import { IVectorLayer } from '@jupytergis/schema'; import { ReadonlyJSONObject } from '@lumino/coreutils'; import { ExpressionValue } from 'ol/expr/expression'; import React, { useEffect, useRef, useState } from 'react'; +import { getNumericFeatureAttributes } from '../../../../tools'; import ColorRamp from '../../components/color_ramp/ColorRamp'; import StopContainer from '../../components/color_stops/StopContainer'; import { useGetProperties } from '../../hooks/useGetProperties'; @@ -25,6 +26,7 @@ const Categorized = ({ const [colorRampOptions, setColorRampOptions] = useState< ReadonlyJSONObject | undefined >(); + const [features, setFeatures] = useState>>({}); if (!layerId) { return; @@ -33,7 +35,7 @@ const Categorized = ({ if (!layer?.parameters) { return; } - const { featureProps } = useGetProperties({ + const { featureProperties } = useGetProperties({ layerId, model: model }); @@ -55,22 +57,23 @@ const Categorized = ({ }, []); useEffect(() => { - populateOptions(); - }, [featureProps]); + // We only want number values here + const numericFeatures = getNumericFeatureAttributes(featureProperties); - useEffect(() => { - selectedValueRef.current = selectedValue; - stopRowsRef.current = stopRows; - colorRampOptionsRef.current = colorRampOptions; - }, [selectedValue, stopRows, colorRampOptions]); + setFeatures(numericFeatures); - const populateOptions = async () => { const layerParams = layer.parameters as IVectorLayer; const value = - layerParams.symbologyState?.value ?? Object.keys(featureProps)[0]; + layerParams.symbologyState?.value ?? Object.keys(numericFeatures)[0]; setSelectedValue(value); - }; + }, [featureProperties]); + + useEffect(() => { + selectedValueRef.current = selectedValue; + stopRowsRef.current = stopRows; + colorRampOptionsRef.current = colorRampOptions; + }, [selectedValue, stopRows, colorRampOptions]); const buildColorInfoFromClassification = ( selectedMode: string, @@ -85,7 +88,7 @@ const Categorized = ({ selectedMode: '' }); - const stops = Array.from(featureProps[selectedValue]).sort((a, b) => a - b); + const stops = Array.from(features[selectedValue]).sort((a, b) => a - b); const valueColorPairs = Utils.getValueColorPairs( stops, @@ -145,7 +148,7 @@ const Categorized = ({ return (
diff --git a/packages/base/src/dialogs/symbology/vector_layer/types/Graduated.tsx b/packages/base/src/dialogs/symbology/vector_layer/types/Graduated.tsx index 53d389ecb..b7f123406 100644 --- a/packages/base/src/dialogs/symbology/vector_layer/types/Graduated.tsx +++ b/packages/base/src/dialogs/symbology/vector_layer/types/Graduated.tsx @@ -1,15 +1,16 @@ +import { IVectorLayer } from '@jupytergis/schema'; import { ExpressionValue } from 'ol/expr/expression'; import React, { useEffect, useRef, useState } from 'react'; +import { getNumericFeatureAttributes } from '../../../../tools'; import { VectorClassifications } from '../../classificationModes'; -import { IStopRow, ISymbologyDialogProps } from '../../symbologyDialog'; import ColorRamp, { ColorRampOptions } from '../../components/color_ramp/ColorRamp'; -import ValueSelect from '../components/ValueSelect'; import StopContainer from '../../components/color_stops/StopContainer'; import { useGetProperties } from '../../hooks/useGetProperties'; +import { IStopRow, ISymbologyDialogProps } from '../../symbologyDialog'; import { Utils, VectorUtils } from '../../symbologyUtils'; -import { IVectorLayer } from '@jupytergis/schema'; +import ValueSelect from '../components/ValueSelect'; const Graduated = ({ model, @@ -35,7 +36,7 @@ const Graduated = ({ const [selectedMethod, setSelectedMethod] = useState('color'); const [stopRows, setStopRows] = useState([]); const [methodOptions, setMethodOptions] = useState(['color']); - + const [features, setFeatures] = useState>>({}); const [colorRampOptions, setColorRampOptions] = useState< ColorRampOptions | undefined >(); @@ -48,7 +49,7 @@ const Graduated = ({ return; } - const { featureProps } = useGetProperties({ + const { featureProperties } = useGetProperties({ layerId, model: model }); @@ -87,24 +88,25 @@ const Graduated = ({ }, [selectedValue, selectedMethod, stopRows, colorRampOptions]); useEffect(() => { - populateOptions(); - }, [featureProps]); - - const populateOptions = async () => { // Set up method options if (layer?.parameters?.type === 'circle') { const options = ['color', 'radius']; setMethodOptions(options); } + // We only want number values here + const numericFeatures = getNumericFeatureAttributes(featureProperties); + + setFeatures(numericFeatures); + const layerParams = layer.parameters as IVectorLayer; const value = - layerParams.symbologyState?.value ?? Object.keys(featureProps)[0]; + layerParams.symbologyState?.value ?? Object.keys(numericFeatures)[0]; const method = layerParams.symbologyState?.method ?? 'color'; setSelectedValue(value); setSelectedMethod(method); - }; + }, [featureProperties]); const handleOk = () => { if (!layer.parameters) { @@ -173,7 +175,7 @@ const Graduated = ({ let stops; - const values = Array.from(featureProps[selectedValue]); + const values = Array.from(features[selectedValue]); switch (selectedMode) { case 'quantile': @@ -230,7 +232,7 @@ const Graduated = ({ return (
diff --git a/packages/base/src/formbuilder/objectform/heatmapLayerForm.ts b/packages/base/src/formbuilder/objectform/heatmapLayerForm.ts index a46bddd80..703b7a36f 100644 --- a/packages/base/src/formbuilder/objectform/heatmapLayerForm.ts +++ b/packages/base/src/formbuilder/objectform/heatmapLayerForm.ts @@ -54,10 +54,6 @@ export class HeatmapLayerPropertiesForm extends LayerPropertiesForm { if (!data) { return; } - - if (this.features.length !== 0) { - schema.properties.feature.enum = this.features; - } } private async fetchFeatureNames( diff --git a/packages/base/src/mainview/TemporalSlider.tsx b/packages/base/src/mainview/TemporalSlider.tsx new file mode 100644 index 000000000..96eaa9f12 --- /dev/null +++ b/packages/base/src/mainview/TemporalSlider.tsx @@ -0,0 +1,449 @@ +import { faPause, faPlay } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Button, Slider } from '@jupyter/react-components'; +import { + IDict, + IJGISFilterItem, + IJGISLayerDocChange, + IJupyterGISDoc, + IJupyterGISModel +} from '@jupytergis/schema'; +import { format, isValid, parse } from 'date-fns'; +import { + daysInYear, + millisecondsInDay, + millisecondsInHour, + millisecondsInMinute, + millisecondsInSecond, + millisecondsInWeek, + minutesInMonth +} from 'date-fns/constants'; +import React, { useEffect, useRef, useState } from 'react'; +import { useGetProperties } from '../dialogs/symbology/hooks/useGetProperties'; + +interface ITemporalSliderProps { + model: IJupyterGISModel; + filterStates: IDict; +} + +// List of common date formats to try +// TODO: Not even close to every valid format +const commonDateFormats = [ + 'yyyy-MM-dd', // ISO format (e.g., 2023-10-05) + 'dd/MM/yyyy', // European format (e.g., 05/10/2023) + 'MM/dd/yyyy', // US format (e.g., 10/05/2023) + 'yyyyMMdd', // Compact format (e.g., 20231005) + 'dd-MM-yyyy', // European format with hyphens (e.g., 05-10-2023) + 'MM-dd-yyyy', // US format with hyphens (e.g., 10-05-2023) + 'yyyy/MM/dd', // ISO format with slashes (e.g., 2023/10/05) + 'dd.MM.yyyy', // European format with dots (e.g., 05.10.2023) + 'MM.dd.yyyy' // US format with dots (e.g., 10.05.2023) +]; + +// Time steps in milliseconds +const stepMap = { + millisecond: 1, + second: millisecondsInSecond, + minute: millisecondsInMinute, + hour: millisecondsInHour, + day: millisecondsInDay, + week: millisecondsInWeek, + month: minutesInMonth * millisecondsInMinute, + year: millisecondsInDay * daysInYear +}; + +const TemporalSlider = ({ model, filterStates }: ITemporalSliderProps) => { + const [layerId, setLayerId] = useState(''); + const [selectedFeature, setSelectedFeature] = useState(''); + const [range, setRange] = useState({ start: 0, end: 1 }); // min/max of current range being displayed + const [minMax, setMinMax] = useState({ min: 0, max: 1 }); // min/max of data values + const [validFeatures, setValidFeatures] = useState([]); + const [dateFormat, setDateFormat] = useState('yyyy-MM-dd'); + const [step, setStep] = useState(stepMap.year); + const [currentValue, setCurrentValue] = useState(0); + const [fps, setFps] = useState(1); + const [validSteps, setValidSteps] = useState({}); + + const layerIdRef = useRef(''); + const intervalRef = useRef(null); + + const { featureProperties } = useGetProperties({ layerId, model }); + + useEffect(() => { + // This is for when the selected layer changes + const handleClientStateChanged = () => { + if (!model.localState?.selected?.value) { + return; + } + + const selectedLayerId = Object.keys(model.localState.selected.value)[0]; + + // reset + if (selectedLayerId !== layerIdRef.current) { + setLayerId(selectedLayerId); + setDateFormat('yyyy-MM-dd'); + setFps(1); + } + }; + + // this is for when the layer itself changes + const handleLayerChange = ( + _: IJupyterGISDoc, + change: IJGISLayerDocChange + ) => { + // Get the changes for the selected layer + const selectedLayer = change.layerChange?.find( + layer => layer.id === layerIdRef.current + ); + + // Bail if there's no relevant change + if (!selectedLayer?.newValue) { + return; + } + + const { newValue, oldValue } = selectedLayer; + + // If layer was deleted (empty object) or the layer type changed, close the temporal controller + if ( + Object.keys(newValue).length === 0 || + newValue.type !== oldValue.type + ) { + model.toggleTemporalController(); + } + }; + + // Initial state + handleClientStateChanged(); + + model.clientStateChanged.connect(handleClientStateChanged); + model.sharedLayersChanged.connect(handleLayerChange); + + return () => { + model.clientStateChanged.disconnect(handleClientStateChanged); + model.sharedLayersChanged.disconnect(handleLayerChange); + removeFilter(); + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + }, []); + + useEffect(() => { + layerIdRef.current = layerId; + }, [layerId]); + + useEffect(() => { + const results: string[] = []; + + for (const [key, set] of Object.entries(featureProperties)) { + if (set.size === 0) { + continue; + } + + const sampleValue = set.values().next().value; + + // Validate value type + const isString = typeof sampleValue === 'string'; + const isInteger = Number.isInteger(sampleValue); + if (!isString && !isInteger) { + continue; + } + + // Date validation + if (isString) { + const dateFormatFromString = determineDateFormat(sampleValue); + + if (!dateFormatFromString) { + continue; + } + setDateFormat(dateFormatFromString); + } + + results.push(key); + } + + // if we have state then remove the ms from the converted feature name + const currentState = filterStates[layerId]; + const currentFeature = currentState?.feature.slice(0, -2); + setValidFeatures(results); + setSelectedFeature(currentFeature ?? results[0]); + }, [featureProperties]); + + useEffect(() => { + if (!selectedFeature || !featureProperties[selectedFeature]) { + return; + } + + // Get and validate values + const valueSet = featureProperties[selectedFeature]; + if (valueSet.size === 0) { + return; + } + + const values = Array.from(valueSet); + const [firstValue] = values; + + // Check the type of the first element + const isString = typeof firstValue === 'string'; + + let convertedValues: number[]; + + if (isString) { + convertedValues = values.map(value => Date.parse(value)); // Convert all strings to milliseconds + } else { + convertedValues = values; // Keep numbers as they are + } + + // Calculate min and max + const min = Math.min(...convertedValues); + const max = Math.max(...convertedValues); + + // Get valid step options + const filteredSteps = Object.fromEntries( + Object.entries(stepMap).filter(([_, val]) => val < max - min) + ); + + //using filter item as a state object to restore prev values + const currentState = filterStates[layerId]; + const step = + Object.values(filteredSteps).slice(-1)[0] ?? stepMap.millisecond; + + setValidSteps(filteredSteps); + setStep(step); + setMinMax({ min, max }); + setRange({ + start: currentState?.betweenMin ?? min, + end: currentState?.betweenMax ?? min + step + }); + + model.addFeatureAsMs(layerId, selectedFeature); + }, [selectedFeature]); + + // minMax needs to be set before current value so the slider displays correctly + useEffect(() => { + const currentState = filterStates[layerId]; + + setCurrentValue( + typeof currentState?.value === 'number' ? currentState.value : minMax.min + ); + }, [minMax]); + + // Guess the date format from a date string + const determineDateFormat = (dateString: string): string | null => { + for (const format of commonDateFormats) { + const parsedDate = parse(dateString, format, new Date()); + if (isValid(parsedDate)) { + return format; // Return the format if the date is valid + } + } + return null; // Return null if no format matches + }; + + // Convert milliseconds back to the original date string format + const millisecondsToDateString = ( + milliseconds: number, + dateFormat: string + ): string => { + const date = new Date(milliseconds); // Create a Date object from milliseconds + return format(date, dateFormat); // Format back to the original string format + }; + + const handleChange = (e: any) => { + setCurrentValue(+e.target.value); + setRange({ start: +e.target.value, end: +e.target.value + step }); + applyFilter(+e.target.value); + }; + + const applyFilter = (value: number) => { + const newFilter = { + feature: `${selectedFeature}ms`, + operator: 'between' as const, + value: value, + betweenMin: value, + betweenMax: value + step + }; + + const layer = model.getLayer(layerId); + if (!layer) { + return; + } + + const appliedFilters = layer.filters?.appliedFilters || []; + const logicalOp = layer.filters?.logicalOp || 'all'; + + // This is the only way to add a 'between' filter so + // find the index of the existing 'between' filter + const betweenFilterIndex = appliedFilters.findIndex( + filter => filter.operator === 'between' + ); + + if (betweenFilterIndex !== -1) { + // If found, replace the existing filter + appliedFilters[betweenFilterIndex] = { + ...newFilter + }; + } else { + // If not found, add the new filter + appliedFilters.push(newFilter); + } + + // Apply the updated filters to the layer + layer.filters = { logicalOp, appliedFilters }; + model.triggerLayerUpdate(layerId, layer); + }; + + const removeFilter = () => { + const layer = model.getLayer(layerIdRef.current); + if (!layer) { + return; + } + + const appliedFilters = layer.filters?.appliedFilters || []; + const logicalOp = layer.filters?.logicalOp || 'all'; + + // This is the only way to add a 'between' filter so + // find the index of the existing 'between' filter + const betweenFilterIndex = appliedFilters.findIndex( + filter => filter.operator === 'between' + ); + + if (betweenFilterIndex !== -1) { + // If found, replace the existing filter + appliedFilters.splice(betweenFilterIndex, 1); + } + + // Apply the updated filters to the layer + layer.filters = { logicalOp, appliedFilters }; + model.triggerLayerUpdate(layerIdRef.current, layer); + }; + + const playAnimation = () => { + // Clear any existing interval first + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + const incrementValue = () => { + setCurrentValue(prev => { + // Calculate next value with safety bounds + const nextValue = prev + step; + + // Clear interval if we've reached the max + // step is subtracted to keep range values correct + if (nextValue >= minMax.max - step && intervalRef.current) { + clearInterval(intervalRef.current); + return minMax.max - step; + } + + return nextValue; + }); + }; + + // Start animation + intervalRef.current = setInterval(incrementValue, 1000 / fps); + }; + + const pauseAnimation = () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + }; + + return ( +
+
+ {/* Feature select */} +
+ + +
+ {/* Current frame */} +
+ Current Frame:{' '} + {millisecondsToDateString(range.start, dateFormat)} ≤ t ≤{' '} + {millisecondsToDateString(range.end, dateFormat)} +
+
+
+ {/* controls */} +
+
+ + +
+
+ + setFps(+e.target.value)} + /> +
+
+ {/* slider */} +
+ +
+
+
+ {/* range */} +
+ Range: + {millisecondsToDateString(minMax.min, dateFormat)} to + {millisecondsToDateString(minMax.max, dateFormat)} +
+ {/* step */} +
+ + +
+
+
+ ); +}; + +export default TemporalSlider; diff --git a/packages/base/src/mainview/mainView.tsx b/packages/base/src/mainview/mainView.tsx index 500f851ad..621ffc830 100644 --- a/packages/base/src/mainview/mainView.tsx +++ b/packages/base/src/mainview/mainView.tsx @@ -7,6 +7,7 @@ import { IHillshadeLayer, IImageLayer, IImageSource, + IJGISFilterItem, IJGISLayer, IJGISLayerDocChange, IJGISLayerTreeDocChange, @@ -27,6 +28,7 @@ import { IWebGlLayer, JupyterGISModel } from '@jupytergis/schema'; +import { showErrorMessage } from '@jupyterlab/apputils'; import { IObservableMap, ObservableMap } from '@jupyterlab/observables'; import { User } from '@jupyterlab/services'; import { CommandRegistry } from '@lumino/commands'; @@ -35,7 +37,7 @@ import { ContextMenu } from '@lumino/widgets'; import { Collection, MapBrowserEvent, Map as OlMap, View, getUid } from 'ol'; //@ts-expect-error no types for ol-pmtiles import { PMTilesRasterSource, PMTilesVectorSource } from 'ol-pmtiles'; -import { FeatureLike } from 'ol/Feature'; +import Feature, { FeatureLike } from 'ol/Feature'; import { ScaleLine } from 'ol/control'; import { Coordinate } from 'ol/coordinate'; import { singleClick } from 'ol/events/condition'; @@ -58,7 +60,7 @@ import { } from 'ol/proj'; import { get as getProjection } from 'ol/proj.js'; import { register } from 'ol/proj/proj4.js'; -import Feature from 'ol/render/Feature'; +import RenderFeature from 'ol/render/Feature'; import { GeoTIFF as GeoTIFFSource, ImageTile as ImageTileSource, @@ -79,9 +81,10 @@ import StatusBar from '../statusbar/StatusBar'; import { isLightTheme, loadFile, throttle } from '../tools'; import CollaboratorPointers, { ClientPointer } from './CollaboratorPointers'; import { FollowIndicator } from './FollowIndicator'; +import TemporalSlider from './TemporalSlider'; import { MainViewModel } from './mainviewmodel'; import { Spinner } from './spinner'; -import { showErrorMessage } from '@jupyterlab/apputils'; +import { Geometry } from 'ol/geom'; interface IProps { viewModel: MainViewModel; @@ -100,6 +103,8 @@ interface IStates { loadingLayer: boolean; scale: number; loadingErrors: Array<{ id: string; error: any; index: number }>; + displayTemporalController: boolean; + filterStates: IDict; } export class MainView extends React.Component { @@ -124,11 +129,13 @@ export class MainView extends React.Component { this._model.sharedLayerTreeChanged.connect(this._onLayerTreeChange, this); this._model.sharedSourcesChanged.connect(this._onSourcesChange, this); this._model.sharedModel.changed.connect(this._onSharedModelStateChange); - this._mainViewModel.jGISModel.sharedMetadataChanged.connect( + this._model.sharedMetadataChanged.connect( this._onSharedMetadataChanged, this ); this._model.zoomToPositionSignal.connect(this._onZoomToPosition, this); + this._model.updateLayerSignal.connect(this._triggerLayerUpdate, this); + this._model.addFeatureAsMsSignal.connect(this._convertFeatureToMs, this); this.state = { id: this._mainViewModel.id, @@ -140,7 +147,9 @@ export class MainView extends React.Component { viewProjection: { code: '', units: '' }, loadingLayer: false, scale: 0, - loadingErrors: [] + loadingErrors: [], + displayTemporalController: false, + filterStates: {} }; this._sources = []; @@ -505,7 +514,7 @@ export class MainView extends React.Component { minZoom: sourceParameters.minZoom, maxZoom: sourceParameters.maxZoom, url: url, - format: new MVT({ featureClass: Feature }) + format: new MVT({ featureClass: RenderFeature }) }); } else { newSource = new PMTilesVectorSource({ @@ -878,9 +887,8 @@ export class MainView extends React.Component { newMapLayer = new HeatmapLayer({ opacity: layerParameters.opacity, source: this._sources[layerParameters.source], - blur: layerParameters.blur, - radius: layerParameters.radius, - weight: layerParameters.feature, + blur: layerParameters.blur ?? 15, + radius: layerParameters.radius ?? 8, gradient: layerParameters.color }); break; @@ -1002,34 +1010,26 @@ export class MainView extends React.Component { const layerStyle = { ...defaultRules }; - if ( - layer.filters && - layer.filters.logicalOp && - layer.filters.appliedFilters.length !== 0 - ) { - const filterExpr: any[] = []; + if (layer.filters?.logicalOp && layer.filters.appliedFilters?.length > 0) { + const buildCondition = (filter: IJGISFilterItem): any[] => { + const base = [filter.operator, ['get', filter.feature]]; + return filter.operator === 'between' + ? [...base, filter.betweenMin, filter.betweenMax] + : [...base, filter.value]; + }; + + let filterExpr: any[]; // 'Any' and 'All' operators require more than one argument // So if there's only one filter, skip that part to avoid error if (layer.filters.appliedFilters.length === 1) { - layer.filters.appliedFilters.forEach(filter => { - filterExpr.push( - filter.operator, - ['get', filter.feature], - filter.value - ); - }); + filterExpr = buildCondition(layer.filters.appliedFilters[0]); } else { - filterExpr.push(layer.filters.logicalOp); - // Arguments for "Any" and 'All' need to be wrapped in brackets - layer.filters.appliedFilters.forEach(filter => { - filterExpr.push([ - filter.operator, - ['get', filter.feature], - filter.value - ]); - }); + filterExpr = [ + layer.filters.logicalOp, + ...layer.filters.appliedFilters.map(buildCondition) + ]; } layerStyle.filter = filterExpr; @@ -1106,8 +1106,8 @@ export class MainView extends React.Component { async updateLayer( id: string, layer: IJGISLayer, - oldLayer: IDict, - mapLayer: Layer + mapLayer: Layer, + oldLayer?: IDict ): Promise { const sourceId = layer.parameters?.source; const source = this._model.sharedModel.getLayerSource(sourceId); @@ -1169,22 +1169,81 @@ export class MainView extends React.Component { const layerParams = layer.parameters as IHeatmapLayer; const heatmap = mapLayer as HeatmapLayer; - if (oldLayer.feature !== layerParams.feature) { - // No way to change 'weight' attribute (feature used for heatmap stuff) so need to replace layer - this.replaceLayer(id, layer); - return; - } - - heatmap.setOpacity(layerParams.opacity || 1); - heatmap.setBlur(layerParams.blur); - heatmap.setRadius(layerParams.radius); + heatmap.setOpacity(layerParams.opacity ?? 1); + heatmap.setBlur(layerParams.blur ?? 15); + heatmap.setRadius(layerParams.radius ?? 8); heatmap.setGradient( layerParams.color ?? ['#00f', '#0ff', '#0f0', '#ff0', '#f00'] ); + + this.handleTemporalController(id, layer); + + break; } } } + /** + * Heatmap layers don't work with style based filtering. + * This modifies the features in the underlying source + * to work with the temporal controller + */ + handleTemporalController = (id: string, layer: IJGISLayer) => { + const selectedLayer = this._model?.localState?.selected?.value; + + // Temporal Controller shouldn't be active if more than one layer is selected + if (!selectedLayer || Object.keys(selectedLayer).length !== 1) { + return; + } + + const selectedLayerId = Object.keys(selectedLayer)[0]; + + // Don't do anything to unselected layers + if (selectedLayerId !== id) { + return; + } + + const layerParams = layer.parameters as IHeatmapLayer; + + const source: VectorSource = this._sources[layerParams.source]; + + if (layer.filters?.appliedFilters.length) { + // Heatmaps don't work with existing filter system so this should be fine + const activeFilter = layer.filters.appliedFilters[0]; + + // Save original features on first filter application + if (!Object.keys(this._originalFeatures).includes(id)) { + this._originalFeatures[id] = source.getFeatures(); + } + + // clear current features + source.clear(); + + const startTime = activeFilter.betweenMin ?? 0; + const endTime = activeFilter.betweenMax ?? 1000; + + const filteredFeatures = this._originalFeatures[id].filter(feature => { + const featureTime = feature.get(activeFilter.feature); + return featureTime >= startTime && featureTime <= endTime; + }); + + // set state for restoration + this.setState(old => ({ + ...old, + filterStates: { + ...this.state.filterStates, + [selectedLayerId]: activeFilter + } + })); + + source.addFeatures(filteredFeatures); + } else { + // Restore original features when no filters are applied + source.addFeatures(this._originalFeatures[id]); + delete this._originalFeatures[id]; + } + }; + /** * Wait for all layers to be loaded. */ @@ -1244,7 +1303,12 @@ export class MainView extends React.Component { sender: IJupyterGISModel, clients: Map ): void => { - const remoteUser = this._model.localState?.remoteUser; + const localState = this._model.localState; + if (!localState) { + return; + } + + const remoteUser = localState.remoteUser; // If we are in following mode, we update our position and selection if (remoteUser) { const remoteState = clients.get(remoteUser); @@ -1268,7 +1332,7 @@ export class MainView extends React.Component { // If we are unfollowing a remote user, we reset our center and zoom to their previous values if (this.state.remoteUser !== null) { this.setState(old => ({ ...old, remoteUser: null })); - const viewportState = this._model.localState?.viewportState?.value; + const viewportState = localState.viewportState?.value; if (viewportState) { this._moveToPosition(viewportState.coordinates, viewportState.zoom); @@ -1319,6 +1383,21 @@ export class MainView extends React.Component { this.setState(old => ({ ...old, clientPointers: clientPointers })); }); + + // Temporal controller bit + // ? There's probably a better way to get changes in the model to trigger react rerenders + const isTemporalControllerActive = localState.isTemporalControllerActive; + + if (isTemporalControllerActive !== this.state.displayTemporalController) { + this.setState(old => ({ + ...old, + displayTemporalController: isTemporalControllerActive + })); + + this._mainViewModel.commands.notifyCommandChanged( + CommandIDs.temporalController + ); + } }; private _onSharedOptionsChanged(): void { @@ -1485,7 +1564,7 @@ export class MainView extends React.Component { } if (layerTree.includes(id)) { - this.updateLayer(id, newLayer, oldLayer, mapLayer); + this.updateLayer(id, newLayer, mapLayer, oldLayer); } else { this.updateLayers(layerTree); } @@ -1714,6 +1793,33 @@ export class MainView extends React.Component { } } + private _triggerLayerUpdate(_: IJupyterGISModel, args: string) { + // ? could send just the filters object and modify that instead of emitting whole layer + const json = JSON.parse(args); + const { layerId, layer: jgisLayer } = json; + const olLayer = this.getLayer(layerId); + + if (!jgisLayer || !olLayer) { + console.log('Layer not found'); + return; + } + + this.updateLayer(layerId, jgisLayer, olLayer); + } + + private _convertFeatureToMs(_: IJupyterGISModel, args: string) { + const json = JSON.parse(args); + const { id: layerId, selectedFeature } = json; + const olLayer = this.getLayer(layerId); + const source = olLayer.getSource() as VectorSource; + + source.forEachFeature(feature => { + const time = feature.get(selectedFeature); + const parsedTime = typeof time === 'string' ? Date.parse(time) : time; + feature.set(`${selectedFeature}ms`, parsedTime); + }); + } + private _handleThemeChange = (): void => { const lightTheme = isLightTheme(); @@ -1755,32 +1861,40 @@ export class MainView extends React.Component { ); })} -
- - - - +
+ {this.state.displayTemporalController && ( + + )}
+ + + + +
+
+
- ); } @@ -1798,4 +1912,5 @@ export class MainView extends React.Component { private _documentPath?: string; private _contextMenu: ContextMenu; private _loadingLayers: Set; + private _originalFeatures: IDict[]> = {}; } diff --git a/packages/base/src/mainview/mainviewmodel.ts b/packages/base/src/mainview/mainviewmodel.ts index c44a664a3..8cd6d1e77 100644 --- a/packages/base/src/mainview/mainviewmodel.ts +++ b/packages/base/src/mainview/mainviewmodel.ts @@ -5,6 +5,7 @@ import { IJupyterGISModel } from '@jupytergis/schema'; import { ObservableMap } from '@jupyterlab/observables'; +import { CommandRegistry } from '@lumino/commands'; import { JSONValue } from '@lumino/coreutils'; import { IDisposable } from '@lumino/disposable'; import { v4 as uuid } from 'uuid'; @@ -13,6 +14,7 @@ export class MainViewModel implements IDisposable { constructor(options: MainViewModel.IOptions) { this._jGISModel = options.jGISModel; this._viewSetting = options.viewSetting; + this._commands = options.commands; } get isDisposed(): boolean { @@ -31,6 +33,10 @@ export class MainViewModel implements IDisposable { return this._viewSetting.changed; } + get commands(): CommandRegistry { + return this._commands; + } + dispose(): void { if (this._isDisposed) { return; @@ -64,6 +70,8 @@ export class MainViewModel implements IDisposable { private _jGISModel: IJupyterGISModel; private _viewSetting: ObservableMap; + private _commands: CommandRegistry; + private _id: string; private _isDisposed = false; } @@ -72,5 +80,6 @@ export namespace MainViewModel { export interface IOptions { jGISModel: IJupyterGISModel; viewSetting: ObservableMap; + commands: CommandRegistry; } } diff --git a/packages/base/src/panelview/leftpanel.tsx b/packages/base/src/panelview/leftpanel.tsx index 1d2ff10c9..d0e0d0ba9 100644 --- a/packages/base/src/panelview/leftpanel.tsx +++ b/packages/base/src/panelview/leftpanel.tsx @@ -13,6 +13,8 @@ import { LayersPanel } from './components/layers'; import { SourcesPanel } from './components/sources'; import { ControlPanelHeader } from './header'; import { FilterPanel } from './components/filter-panel/Filter'; +import { CommandRegistry } from '@lumino/commands'; +import { CommandIDs } from '../constants'; /** * Options of the left panel widget. @@ -40,6 +42,7 @@ export class LeftPanelWidget extends SidePanel { this._model = options.model; this._state = options.state; + this._commands = options.commands; const header = new ControlPanelHeader(); this.header.addWidget(header); @@ -178,6 +181,7 @@ export class LeftPanelWidget extends SidePanel { this._lastSelectedNodeId = nodeId; jGISModel.syncSelected(updatedSelectedValue, this.id); + this._commands.notifyCommandChanged(CommandIDs.temporalController); } }; @@ -191,11 +195,13 @@ export class LeftPanelWidget extends SidePanel { this._lastSelectedNodeId = nodeId; } this._model?.jGISModel?.syncSelected(selection, this.id); + this._commands.notifyCommandChanged(CommandIDs.temporalController); } private _lastSelectedNodeId: string; private _model: IControlPanelModel; private _state: IStateDB; + private _commands: CommandRegistry; } export namespace LeftPanelWidget { @@ -203,6 +209,7 @@ export namespace LeftPanelWidget { model: IControlPanelModel; tracker: IJupyterGISTracker; state: IStateDB; + commands: CommandRegistry; } export interface IProps { diff --git a/packages/base/src/toolbar/widget.tsx b/packages/base/src/toolbar/widget.tsx index b20b3b67e..ce19e8c53 100644 --- a/packages/base/src/toolbar/widget.tsx +++ b/packages/base/src/toolbar/widget.tsx @@ -172,6 +172,15 @@ export class ToolbarWidget extends ReactiveToolbar { }) ); + this.addItem( + 'temporalController', + new CommandToolbarButton({ + id: CommandIDs.temporalController, + label: '', + commands: options.commands + }) + ); + this.addItem('spacer', ReactiveToolbar.createSpacerItem()); // Users diff --git a/packages/base/src/tools.ts b/packages/base/src/tools.ts index 40bf79ef0..71947592e 100644 --- a/packages/base/src/tools.ts +++ b/packages/base/src/tools.ts @@ -867,3 +867,24 @@ export const stringToArrayBuffer = async ( ); return await base64Response.arrayBuffer(); }; + +export const getNumericFeatureAttributes = ( + featureProperties: Record> +) => { + // We only want number values here + const filteredRecord: Record> = {}; + + for (const [key, set] of Object.entries(featureProperties)) { + const firstValue = set.values().next().value; + + // Check if the first value is a string that cannot be parsed as a number + const isInvalidString = + typeof firstValue === 'string' && isNaN(Number(firstValue)); + + if (!isInvalidString) { + filteredRecord[key] = set; + } + } + + return filteredRecord; +}; diff --git a/packages/base/src/widget.ts b/packages/base/src/widget.ts index a2216d44c..172f57955 100644 --- a/packages/base/src/widget.ts +++ b/packages/base/src/widget.ts @@ -15,6 +15,7 @@ import { JupyterGISMainViewPanel } from './mainview'; import { MainViewModel } from './mainview/mainviewmodel'; import { ConsoleView } from './console'; import { MessageLoop } from '@lumino/messaging'; +import { CommandRegistry } from '@lumino/commands'; const CELL_OUTPUT_WIDGET_CLASS = 'jgis-cell-output-widget'; @@ -95,18 +96,23 @@ export namespace JupyterGISOutputWidget { export class JupyterGISPanel extends SplitPanel { constructor(options: JupyterGISPanel.IOptions) { super({ orientation: 'vertical', spacing: 0 }); - const { model, consoleTracker, ...consoleOption } = options; - this._initModel({ model }); + const { model, consoleTracker, commandRegistry, ...consoleOption } = + options; + this._initModel({ model, commandRegistry }); this._initView(); this._consoleOption = consoleOption; this._consoleTracker = consoleTracker; } - _initModel(options: { model: IJupyterGISModel }) { + _initModel(options: { + model: IJupyterGISModel; + commandRegistry: CommandRegistry; + }) { this._view = new ObservableMap(); this._mainViewModel = new MainViewModel({ jGISModel: options.model, - viewSetting: this._view + viewSetting: this._view, + commands: options.commandRegistry }); } @@ -235,6 +241,7 @@ export class JupyterGISPanel extends SplitPanel { export namespace JupyterGISPanel { export interface IOptions extends Partial { model: IJupyterGISModel; + commandRegistry: CommandRegistry; consoleTracker?: IConsoleTracker; } } diff --git a/packages/base/style/base.css b/packages/base/style/base.css index fae7cdabe..9ab46b86d 100644 --- a/packages/base/style/base.css +++ b/packages/base/style/base.css @@ -9,6 +9,7 @@ @import url('./filterPanel.css'); @import url('./symbologyDialog.css'); @import url('./statusBar.css'); +@import url('./temporalSlider.css'); @import url('ol/ol.css'); .jGIS-Toolbar-GroupName { diff --git a/packages/base/style/temporalSlider.css b/packages/base/style/temporalSlider.css new file mode 100644 index 000000000..4df1df185 --- /dev/null +++ b/packages/base/style/temporalSlider.css @@ -0,0 +1,47 @@ +.jp-gis-temporal-slider-container { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding: 0.5rem; + background-color: var(--jp-layout-color1); + border-bottom: 1px solid var(--jp-border-color1); +} + +.jp-gis-temporal-slider-container span, +.jp-gis-temporal-slider-container label { + font-weight: bold; +} + +.jp-gis-temporal-slider-row { + display: flex; + gap: 0.25rem; + justify-content: space-between; + align-items: center; +} + +.jp-gis-temporal-slider-controls { + display: flex; + flex-grow: 1; + justify-content: space-around; +} + +.jp-gis-temporal-slider-sub-controls { + display: flex; + justify-content: center; + align-items: center; + gap: 0.25rem; +} + +.jp-gis-temporal-slider-sub-controls > input { + width: 35px; +} + +select, +select option { + text-transform: capitalize; +} + +.jp-gis-temporal-slider { + flex: 1 0 40%; + min-width: 300px; +} diff --git a/packages/schema/src/interfaces.ts b/packages/schema/src/interfaces.ts index 6c3ec2687..dc1038447 100644 --- a/packages/schema/src/interfaces.ts +++ b/packages/schema/src/interfaces.ts @@ -78,6 +78,7 @@ export interface IJupyterGISClientState { user: User.IIdentity; remoteUser?: number; toolbarForm?: IDict; + isTemporalControllerActive: boolean; } export interface IJupyterGISDoc extends YDocument { @@ -167,6 +168,8 @@ export interface IJupyterGISModel extends DocumentRegistry.IModel { sharedSourcesChanged: ISignal; sharedMetadataChanged: ISignal; zoomToPositionSignal: ISignal; + addFeatureAsMsSignal: ISignal; + updateLayerSignal: ISignal; contentsManager: Contents.IManager | undefined; filePath: string; @@ -213,6 +216,11 @@ export interface IJupyterGISModel extends DocumentRegistry.IModel { toggleIdentify(): void; isIdentifying: boolean; + isTemporalControllerActive: boolean; + toggleTemporalController(): void; + addFeatureAsMs(id: string, selectedFeature: string): void; + triggerLayerUpdate(layerId: string, layer: IJGISLayer): void; + disposed: ISignal; } diff --git a/packages/schema/src/model.ts b/packages/schema/src/model.ts index ee0c8ae70..26f81110f 100644 --- a/packages/schema/src/model.ts +++ b/packages/schema/src/model.ts @@ -1,10 +1,10 @@ import { MapChange } from '@jupyter/ydoc'; import { IChangedArgs } from '@jupyterlab/coreutils'; import { DocumentRegistry } from '@jupyterlab/docregistry'; +import { Contents } from '@jupyterlab/services'; import { PartialJSONObject } from '@lumino/coreutils'; import { ISignal, Signal } from '@lumino/signaling'; import Ajv from 'ajv'; - import { IJGISContent, IJGISLayer, @@ -18,9 +18,8 @@ import { } from './_interface/jgis'; import { JupyterGISDoc } from './doc'; import { - IViewPortState, - Pointer, IAnnotationModel, + IDict, IJGISLayerDocChange, IJGISLayerTreeDocChange, IJGISSourceDocChange, @@ -29,10 +28,10 @@ import { IJupyterGISModel, ISelection, IUserData, - IDict + IViewPortState, + Pointer } from './interfaces'; import jgisSchema from './schema/jgis.json'; -import { Contents } from '@jupyterlab/services'; export class JupyterGISModel implements IJupyterGISModel { constructor(options: JupyterGISModel.IOptions) { @@ -166,6 +165,14 @@ export class JupyterGISModel implements IJupyterGISModel { return this._isIdentifying; } + set isTemporalControllerActive(isActive: boolean) { + this._isTemporalControllerActive = isActive; + } + + get isTemporalControllerActive(): boolean { + return this._isTemporalControllerActive; + } + centerOnPosition(id: string) { this._zoomToPositionSignal.emit(id); } @@ -620,6 +627,15 @@ export class JupyterGISModel implements IJupyterGISModel { this._isIdentifying = !this._isIdentifying; } + toggleTemporalController() { + this._isTemporalControllerActive = !this._isTemporalControllerActive; + + this.sharedModel.awareness.setLocalStateField( + 'isTemporalControllerActive', + this._isTemporalControllerActive + ); + } + private _getLayerTreeInfo(groupName: string): | { mainGroup: IJGISLayerGroup; @@ -668,6 +684,22 @@ export class JupyterGISModel implements IJupyterGISModel { } }; + addFeatureAsMs = (id: string, selectedFeature: string) => { + this.addFeatureAsMsSignal.emit(JSON.stringify({ id, selectedFeature })); + }; + + get addFeatureAsMsSignal() { + return this._addFeatureAsMsSignal; + } + + get updateLayerSignal() { + return this._updateLayerSignal; + } + + triggerLayerUpdate = (layerId: string, layer: IJGISLayer) => { + this.updateLayerSignal.emit(JSON.stringify({ layerId, layer })); + }; + readonly defaultKernelName: string = ''; readonly defaultKernelLanguage: string = ''; readonly annotationModel?: IAnnotationModel; @@ -693,7 +725,12 @@ export class JupyterGISModel implements IJupyterGISModel { private _sharedMetadataChanged = new Signal(this); private _zoomToPositionSignal = new Signal(this); + private _addFeatureAsMsSignal = new Signal(this); + + private _updateLayerSignal = new Signal(this); + private _isIdentifying = false; + private _isTemporalControllerActive = false; static worker: Worker; } diff --git a/packages/schema/src/schema/heatmapLayer.json b/packages/schema/src/schema/heatmapLayer.json index 39605fec1..9b654d788 100644 --- a/packages/schema/src/schema/heatmapLayer.json +++ b/packages/schema/src/schema/heatmapLayer.json @@ -2,17 +2,13 @@ "type": "object", "description": "HeatmapLayer", "title": "IHeatmapLayer", - "required": ["source", "blur", "radius", "feature"], + "required": ["source", "blur", "radius"], "additionalProperties": false, "properties": { "source": { "type": "string", "description": "The id of the source" }, - "feature": { - "type": "string", - "description": "The feature to use" - }, "opacity": { "type": "number", "description": "The opacity of the source", diff --git a/packages/schema/src/schema/jgis.json b/packages/schema/src/schema/jgis.json index 50813af40..43fd3bbbf 100644 --- a/packages/schema/src/schema/jgis.json +++ b/packages/schema/src/schema/jgis.json @@ -206,7 +206,7 @@ "properties": { "operator": { "type": "string", - "enum": ["==", "!=", ">", "<", ">=", "<="], + "enum": ["==", "!=", ">", "<", ">=", "<=", "between"], "default": "==" }, "feature": { @@ -215,6 +215,12 @@ }, "value": { "type": ["string", "number"] + }, + "betweenMin": { + "type": ["number"] + }, + "betweenMax": { + "type": ["number"] } } }, diff --git a/python/jupytergis_lab/src/index.ts b/python/jupytergis_lab/src/index.ts index 08b2039e7..757436c96 100644 --- a/python/jupytergis_lab/src/index.ts +++ b/python/jupytergis_lab/src/index.ts @@ -270,7 +270,8 @@ const controlPanel: JupyterFrontEndPlugin = { const leftControlPanel = new LeftPanelWidget({ model: controlModel, tracker, - state + state, + commands: app.commands }); leftControlPanel.id = 'jupytergis::leftControlPanel'; leftControlPanel.title.caption = 'JupyterGIS Control Panel'; diff --git a/python/jupytergis_lab/src/notebookrenderer.ts b/python/jupytergis_lab/src/notebookrenderer.ts index afa3f4209..cea775ad3 100644 --- a/python/jupytergis_lab/src/notebookrenderer.ts +++ b/python/jupytergis_lab/src/notebookrenderer.ts @@ -81,7 +81,7 @@ export class YJupyterGISLuminoWidget extends Panel { */ private _buildWidget = (options: IOptions) => { const { commands, model, externalCommands, tracker } = options; - const content = new JupyterGISPanel({ model }); + const content = new JupyterGISPanel({ model, commandRegistry: commands }); let toolbar: Toolbar | undefined = undefined; if (model.filePath) { toolbar = new ToolbarWidget({ diff --git a/python/jupytergis_lab/style/base.css b/python/jupytergis_lab/style/base.css index b4355fb06..78dba813e 100644 --- a/python/jupytergis_lab/style/base.css +++ b/python/jupytergis_lab/style/base.css @@ -212,10 +212,16 @@ div.jGIS-toolbar-widget > div.jp-Toolbar-item:last-child { border: solid 1.5px red; } +.jGIS-Mainview-Container { + display: flex; + flex-direction: column; + height: 100%; +} + .jGIS-Mainview { width: 100%; - height: calc(100% - 16px); box-sizing: border-box; + flex: 1; } .jGIS-Popup-Wrapper { diff --git a/yarn.lock b/yarn.lock index 3740c67e0..c27a426a2 100644 --- a/yarn.lock +++ b/yarn.lock @@ -767,6 +767,7 @@ __metadata: ajv: ^8.14.0 colormap: ^2.3.2 d3-color: ^3.1.0 + date-fns: ^4.1.0 gdal3.js: ^2.8.1 geojson-vt: ^4.0.2 geotiff: ^2.1.3 @@ -4563,6 +4564,13 @@ __metadata: languageName: node linkType: hard +"date-fns@npm:^4.1.0": + version: 4.1.0 + resolution: "date-fns@npm:4.1.0" + checksum: fb681b242cccabed45494468f64282a7d375ea970e0adbcc5dcc92dcb7aba49b2081c2c9739d41bf71ce89ed68dd73bebfe06ca35129490704775d091895710b + languageName: node + linkType: hard + "dateformat@npm:^3.0.3": version: 3.0.3 resolution: "dateformat@npm:3.0.3"