diff --git a/src/renderer/Clipboard.js b/src/renderer/Clipboard.js index 4368e62b..fc0202cf 100644 --- a/src/renderer/Clipboard.js +++ b/src/renderer/Clipboard.js @@ -7,7 +7,8 @@ const canCopy = id => ID.isLayerId(id) || ID.isFeatureId(id) || ID.isMarkerId(id) || - ID.isTileServiceId(id) + ID.isTileServiceId(id) || + ID.isSSEServiceId(id) /** * @async implicit diff --git a/src/renderer/components/map/SSEVectorSource.js b/src/renderer/components/map/SSEVectorSource.js index bd5b66a8..aa4bece5 100644 --- a/src/renderer/components/map/SSEVectorSource.js +++ b/src/renderer/components/map/SSEVectorSource.js @@ -84,7 +84,7 @@ class SSEVectorSource extends VectorSource { strategy: function (extent, resolution) { if (state.resolution !== resolution) { state.resolution = resolution - this.getFeatures().map(feature => feature.$.centerResolution(resolution)) + this.getFeatures().forEach(feature => feature.$?.centerResolution(resolution)) } return [extent] } @@ -128,6 +128,30 @@ class SSEVectorSource extends VectorSource { */ this.useFeatureIds = options.useFeatureIds !== false + /** + * Render mode: 'vector' for normal rendering, 'heatmap' for density visualization + * @type {string} + */ + this.renderMode = options.renderMode || 'vector' + + /** + * Rate limiting interval for heatmap accumulation in milliseconds + * @type {number} + */ + this.heatmapInterval = options.heatmapInterval || 1000 + + /** + * Timestamp of last heatmap accumulation (ms since epoch) + * @type {number} + */ + this.lastHeatmapUpdate = 0 + + /** + * Maximum number of accumulated heatmap features before oldest are dropped + * @type {number} + */ + this.maxHeatmapFeatures = options.maxHeatmapFeatures || 50000 + /** * Prefix added to feature IDs * @type {string} @@ -233,7 +257,13 @@ class SSEVectorSource extends VectorSource { try { /** @type {GeoJSONData} */ const data = JSON.parse(event.data) - if (data.id) { + if (data.type === 'FeatureCollection' && data.features) { + data.features.forEach(feature => { + if (feature.id) { + feature.id = `${this.idPrefix}${feature.id}` + } + }) + } else if (data.id) { data.id = `${this.idPrefix}${data.id}` } if (this.onMessage) { @@ -279,6 +309,8 @@ class SSEVectorSource extends VectorSource { this.timerId = null } + this.lastHeatmapUpdate = 0 + this.dispatchEvent({ type: 'sse-disconnected', source: this @@ -330,7 +362,13 @@ class SSEVectorSource extends VectorSource { console.warn('Unknown feature type ' + this.pendingData.type) } - if (this.useFeatureIds) { + if (this.renderMode === 'heatmap') { + const now = Date.now() + if (now - this.lastHeatmapUpdate >= this.heatmapInterval) { + this.accumulateFeatures(features) + this.lastHeatmapUpdate = now + } + } else if (this.useFeatureIds) { this.updateFeaturesById(features) } else { this.replaceAllFeatures(features) @@ -395,6 +433,37 @@ class SSEVectorSource extends VectorSource { this.addFeatures(olFeatures) } + /** + * Accumulates features without clearing existing ones (for heatmap mode). + * Uses lightweight format.readFeature() instead of signal-based featureReader. + * Maps properties.confidence to a 'weight' property for heatmap rendering. + * + * @param {GeoJSONFeature[]} features - Array of GeoJSON features + * @returns {void} + */ + accumulateFeatures (features) { + const olFeatures = features.map(feature => { + const reprojectedFeature = { + ...feature, + geometry: this.reprojectGeometry(feature.geometry) + } + const olFeature = format.readFeature(reprojectedFeature) + const confidence = feature.properties?.confidence + olFeature.set('weight', confidence != null ? confidence : 0.5) + return olFeature + }) + this.addFeatures(olFeatures) + + // Evict oldest features when cap is exceeded + const all = this.getFeatures() + const excess = all.length - this.maxHeatmapFeatures + if (excess > 0) { + for (let i = 0; i < excess; i++) { + this.removeFeature(all[i]) + } + } + } + /** * Updates existing features or adds new ones based on feature ID. * Features without IDs are skipped with a warning. diff --git a/src/renderer/components/properties/SSEServiceProperties.css b/src/renderer/components/properties/SSEServiceProperties.css index 10dbc887..e3e80f97 100644 --- a/src/renderer/components/properties/SSEServiceProperties.css +++ b/src/renderer/components/properties/SSEServiceProperties.css @@ -1,3 +1,32 @@ +.sse-section-label { + font-size: 0.85rem; + font-weight: 500; + color: #666; +} + +.sse-render-mode { + display: flex; + flex-direction: column; + gap: 4px; + padding: 4px 0; +} + +.sse-radio-group { + display: flex; + gap: 16px; +} + +.sse-radio-label { + display: flex; + align-items: center; + gap: 6px; + cursor: pointer; +} + +.sse-radio-label input:disabled + span { + color: #999; +} + .sse-option-row { display: flex; align-items: center; diff --git a/src/renderer/components/properties/SSEServiceProperties.js b/src/renderer/components/properties/SSEServiceProperties.js index ce099b88..938c036c 100644 --- a/src/renderer/components/properties/SSEServiceProperties.js +++ b/src/renderer/components/properties/SSEServiceProperties.js @@ -1,30 +1,112 @@ /* eslint-disable react/prop-types */ import React from 'react' import TextField from './TextField' +import Range from './Range' import FlexColumnGap from './FlexColumnGap' import Name from './Name' import { useServices } from '../hooks' import { Tooltip } from 'react-tooltip' import './SSEServiceProperties.css' +/** + * Displays the connection status indicator (green/gray dot with text). + * Polls independently so re-renders don't affect the parent form. + */ +const SSEConnectionStatus = ({ sseLayerStore, serviceKey }) => { + const [connected, setConnected] = React.useState(false) + + React.useEffect(() => { + const check = () => { + if (sseLayerStore) { + const stats = sseLayerStore.getServiceStats(serviceKey) + setConnected(!!stats.isConnected) + } + } + check() + const interval = setInterval(check, 1000) + return () => clearInterval(interval) + }, [serviceKey, sseLayerStore]) + + const className = connected + ? 'sse-status sse-status--connected' + : 'sse-status sse-status--disconnected' + + return ( +