add synchronized dual-display workbench
This commit is contained in:
@@ -9,6 +9,7 @@ import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/da
|
||||
import { TemporalTrendChart } from './TemporalTrendChart'
|
||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||
import { LiveAnalysisJourney } from './LiveAnalysisJourney'
|
||||
import { SecondaryDisplayTarget } from '../shell/SecondaryDisplay'
|
||||
import { terrainImageUrl } from '../../lib/terrainImage'
|
||||
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
||||
import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster'
|
||||
@@ -645,6 +646,7 @@ function floodScenarioLabel(dataset: DatasetCreateResponse): string {
|
||||
|
||||
interface MapWorkspaceProps {
|
||||
readOnly?: boolean
|
||||
secondaryResultsContainer?: HTMLElement | null
|
||||
selectedProjectId: string | null
|
||||
projects: ProjectRead[]
|
||||
areas: AreaRead[]
|
||||
@@ -740,6 +742,7 @@ interface MapWorkspaceProps {
|
||||
|
||||
export function MapWorkspace({
|
||||
readOnly = false,
|
||||
secondaryResultsContainer = null,
|
||||
selectedProjectId,
|
||||
projects,
|
||||
areas,
|
||||
@@ -2689,25 +2692,28 @@ export function MapWorkspace({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className={resultsPanelOpen ? 'geo-results-toggle geo-results-toggle-open' : 'geo-results-toggle'}
|
||||
type="button"
|
||||
aria-controls="geo-explorer-results"
|
||||
aria-expanded={resultsPanelOpen}
|
||||
aria-label={resultsPanelOpen ? 'Sluit inzichten' : 'Open inzichten'}
|
||||
onClick={() => setResultsPanelOpen((open) => !open)}
|
||||
>
|
||||
{resultsPanelOpen ? <ChevronRight aria-hidden="true" /> : <ChevronLeft aria-hidden="true" />}
|
||||
<span>{resultsPanelOpen ? 'Sluit inzichten' : 'Inzichten'}</span>
|
||||
</button>
|
||||
{secondaryResultsContainer ? null : (
|
||||
<button
|
||||
className={resultsPanelOpen ? 'geo-results-toggle geo-results-toggle-open' : 'geo-results-toggle'}
|
||||
type="button"
|
||||
aria-controls="geo-explorer-results"
|
||||
aria-expanded={resultsPanelOpen}
|
||||
aria-label={resultsPanelOpen ? 'Sluit inzichten' : 'Open inzichten'}
|
||||
onClick={() => setResultsPanelOpen((open) => !open)}
|
||||
>
|
||||
{resultsPanelOpen ? <ChevronRight aria-hidden="true" /> : <ChevronLeft aria-hidden="true" />}
|
||||
<span>{resultsPanelOpen ? 'Sluit inzichten' : 'Inzichten'}</span>
|
||||
</button>
|
||||
)}
|
||||
|
||||
<aside
|
||||
id="geo-explorer-results"
|
||||
className={resultsPanelOpen ? 'geo-results-panel geo-results-panel-open' : 'geo-results-panel'}
|
||||
aria-label="Gebiedsanalyse"
|
||||
aria-live="polite"
|
||||
aria-hidden={!resultsPanelOpen}
|
||||
>
|
||||
<SecondaryDisplayTarget container={secondaryResultsContainer}>
|
||||
<aside
|
||||
id={secondaryResultsContainer ? 'geo-secondary-results' : 'geo-explorer-results'}
|
||||
className={secondaryResultsContainer ? 'geo-results-panel geo-results-panel-open geo-results-panel-secondary' : resultsPanelOpen ? 'geo-results-panel geo-results-panel-open' : 'geo-results-panel'}
|
||||
aria-label="Gebiedsanalyse"
|
||||
aria-live="polite"
|
||||
aria-hidden={secondaryResultsContainer ? false : !resultsPanelOpen}
|
||||
>
|
||||
<div className="geo-panel-heading">
|
||||
<div>
|
||||
<h3>Inzichten</h3>
|
||||
@@ -3031,7 +3037,8 @@ export function MapWorkspace({
|
||||
: ''}
|
||||
</p>
|
||||
) : null}
|
||||
</aside>
|
||||
</aside>
|
||||
</SecondaryDisplayTarget>
|
||||
</div>
|
||||
|
||||
<footer className="geo-explorer-footer">
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { parseSecondaryDisplayGeometry, secondaryDisplayFeatures } from './secondaryDisplayGeometry'
|
||||
|
||||
describe('secondary display geometry', () => {
|
||||
it('restores a valid remembered window position', () => {
|
||||
expect(parseSecondaryDisplayGeometry('{"left":1920,"top":0,"width":720,"height":1040}')).toEqual({
|
||||
left: 1920,
|
||||
top: 0,
|
||||
width: 720,
|
||||
height: 1040,
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects malformed state and bounds unsafe dimensions', () => {
|
||||
expect(parseSecondaryDisplayGeometry('not-json')).toBeNull()
|
||||
expect(parseSecondaryDisplayGeometry('{"left":0,"top":0,"width":2,"height":99999}')).toEqual({
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 420,
|
||||
height: 2160,
|
||||
})
|
||||
})
|
||||
|
||||
it('builds a resizable popup feature string', () => {
|
||||
expect(secondaryDisplayFeatures({ left: 1920, top: 10, width: 700, height: 900 })).toContain(
|
||||
'left=1920,top=10,width=700,height=900,resizable=yes,scrollbars=yes',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,166 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import {
|
||||
parseSecondaryDisplayGeometry,
|
||||
secondaryDisplayFeatures,
|
||||
type SecondaryDisplayGeometry,
|
||||
} from './secondaryDisplayGeometry'
|
||||
|
||||
const SECONDARY_DISPLAY_NAME = 'geointel-analysis-console'
|
||||
const SECONDARY_DISPLAY_GEOMETRY_KEY = 'geointel.secondary-display.geometry.v1'
|
||||
|
||||
function defaultGeometry(): SecondaryDisplayGeometry {
|
||||
return {
|
||||
left: window.screenX + Math.max(80, window.outerWidth - 32),
|
||||
top: window.screenY,
|
||||
width: 620,
|
||||
height: Math.max(640, Math.min(1_080, window.outerHeight)),
|
||||
}
|
||||
}
|
||||
|
||||
function copyDocumentStyles(target: Document): void {
|
||||
document.head.querySelectorAll<HTMLLinkElement | HTMLStyleElement>('link[rel="stylesheet"], style').forEach((node) => {
|
||||
const clone = node.cloneNode(true) as HTMLLinkElement | HTMLStyleElement
|
||||
if (clone instanceof HTMLLinkElement) clone.href = node instanceof HTMLLinkElement ? node.href : ''
|
||||
target.head.append(clone)
|
||||
})
|
||||
}
|
||||
|
||||
function initialiseSecondaryDocument(target: Window, onClose: () => void): HTMLElement {
|
||||
const targetDocument = target.document
|
||||
targetDocument.title = 'GeoIntel · Analyseconsole'
|
||||
targetDocument.documentElement.lang = document.documentElement.lang || 'nl'
|
||||
targetDocument.head.replaceChildren()
|
||||
|
||||
const viewport = targetDocument.createElement('meta')
|
||||
viewport.name = 'viewport'
|
||||
viewport.content = 'width=device-width, initial-scale=1'
|
||||
targetDocument.head.append(viewport)
|
||||
copyDocumentStyles(targetDocument)
|
||||
|
||||
const shell = targetDocument.createElement('div')
|
||||
shell.className = 'secondary-display-shell'
|
||||
|
||||
const header = targetDocument.createElement('header')
|
||||
header.className = 'secondary-display-header'
|
||||
header.innerHTML = `
|
||||
<div class="secondary-display-brand">
|
||||
<img src="/geointel-icon.svg" alt="" aria-hidden="true" />
|
||||
<span><small>LIVE GEKOPPELD</small><strong>Analyseconsole</strong></span>
|
||||
</div>
|
||||
<div class="secondary-display-status"><i aria-hidden="true"></i><span>Gesynchroniseerd met de kaart</span></div>
|
||||
`
|
||||
const closeButton = targetDocument.createElement('button')
|
||||
closeButton.type = 'button'
|
||||
closeButton.className = 'secondary-display-close'
|
||||
closeButton.textContent = 'Sluit console'
|
||||
closeButton.addEventListener('click', onClose)
|
||||
header.append(closeButton)
|
||||
|
||||
const content = targetDocument.createElement('main')
|
||||
content.className = 'secondary-display-content'
|
||||
content.id = 'secondary-display-content'
|
||||
content.setAttribute('aria-label', 'Live analyse en resultaten')
|
||||
|
||||
shell.append(header, content)
|
||||
targetDocument.body.replaceChildren(shell)
|
||||
targetDocument.body.className = 'secondary-display-body'
|
||||
return content
|
||||
}
|
||||
|
||||
export function useSecondaryDisplay() {
|
||||
const popupRef = useRef<Window | null>(null)
|
||||
const monitorTimerRef = useRef<number | null>(null)
|
||||
const [container, setContainer] = useState<HTMLElement | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const stopMonitoring = useCallback(() => {
|
||||
if (monitorTimerRef.current !== null) {
|
||||
window.clearInterval(monitorTimerRef.current)
|
||||
monitorTimerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const rememberGeometry = useCallback((popup: Window) => {
|
||||
const geometry: SecondaryDisplayGeometry = {
|
||||
left: popup.screenX,
|
||||
top: popup.screenY,
|
||||
width: popup.outerWidth,
|
||||
height: popup.outerHeight,
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(SECONDARY_DISPLAY_GEOMETRY_KEY, JSON.stringify(geometry))
|
||||
} catch {
|
||||
// Window positioning is a convenience; storage restrictions must never
|
||||
// break the live analysis console.
|
||||
}
|
||||
}, [])
|
||||
|
||||
const close = useCallback(() => {
|
||||
const popup = popupRef.current
|
||||
if (popup && !popup.closed) {
|
||||
rememberGeometry(popup)
|
||||
popup.close()
|
||||
}
|
||||
popupRef.current = null
|
||||
setContainer(null)
|
||||
stopMonitoring()
|
||||
}, [rememberGeometry, stopMonitoring])
|
||||
|
||||
const open = useCallback(() => {
|
||||
const existing = popupRef.current
|
||||
if (existing && !existing.closed) {
|
||||
existing.focus()
|
||||
return
|
||||
}
|
||||
|
||||
let saved: SecondaryDisplayGeometry | null = null
|
||||
try {
|
||||
saved = parseSecondaryDisplayGeometry(window.localStorage.getItem(SECONDARY_DISPLAY_GEOMETRY_KEY))
|
||||
} catch {
|
||||
saved = null
|
||||
}
|
||||
const popup = window.open('', SECONDARY_DISPLAY_NAME, secondaryDisplayFeatures(saved ?? defaultGeometry()))
|
||||
if (!popup) {
|
||||
setError('De browser blokkeerde het tweede venster. Sta pop-ups voor GeoIntel toe en probeer opnieuw.')
|
||||
return
|
||||
}
|
||||
|
||||
setError(null)
|
||||
popupRef.current = popup
|
||||
const content = initialiseSecondaryDocument(popup, close)
|
||||
setContainer(content)
|
||||
popup.focus()
|
||||
|
||||
stopMonitoring()
|
||||
monitorTimerRef.current = window.setInterval(() => {
|
||||
const current = popupRef.current
|
||||
if (!current || current.closed) {
|
||||
popupRef.current = null
|
||||
setContainer(null)
|
||||
stopMonitoring()
|
||||
return
|
||||
}
|
||||
rememberGeometry(current)
|
||||
}, 1_000)
|
||||
}, [close, rememberGeometry, stopMonitoring])
|
||||
|
||||
useEffect(() => close, [close])
|
||||
|
||||
return {
|
||||
container,
|
||||
error,
|
||||
isOpen: container !== null,
|
||||
open,
|
||||
close,
|
||||
}
|
||||
}
|
||||
|
||||
interface SecondaryDisplayTargetProps {
|
||||
container: HTMLElement | null
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function SecondaryDisplayTarget({ container, children }: SecondaryDisplayTargetProps) {
|
||||
return container ? createPortal(children, container) : children
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
export interface SecondaryDisplayGeometry {
|
||||
left: number
|
||||
top: number
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, minimum: number, maximum: number): number | null {
|
||||
const parsed = typeof value === 'number' ? value : Number(value)
|
||||
if (!Number.isFinite(parsed)) return null
|
||||
return Math.min(maximum, Math.max(minimum, Math.round(parsed)))
|
||||
}
|
||||
|
||||
export function parseSecondaryDisplayGeometry(value: string | null): SecondaryDisplayGeometry | null {
|
||||
if (!value) return null
|
||||
try {
|
||||
const parsed = JSON.parse(value) as Partial<SecondaryDisplayGeometry>
|
||||
const left = boundedInteger(parsed.left, -20_000, 20_000)
|
||||
const top = boundedInteger(parsed.top, -20_000, 20_000)
|
||||
const width = boundedInteger(parsed.width, 420, 3_840)
|
||||
const height = boundedInteger(parsed.height, 520, 2_160)
|
||||
return left === null || top === null || width === null || height === null
|
||||
? null
|
||||
: { left, top, width, height }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function secondaryDisplayFeatures(geometry: SecondaryDisplayGeometry): string {
|
||||
return [
|
||||
'popup=yes',
|
||||
`left=${geometry.left}`,
|
||||
`top=${geometry.top}`,
|
||||
`width=${geometry.width}`,
|
||||
`height=${geometry.height}`,
|
||||
'resizable=yes',
|
||||
'scrollbars=yes',
|
||||
].join(',')
|
||||
}
|
||||
Reference in New Issue
Block a user