add synchronized dual-display workbench
This commit is contained in:
@@ -1,5 +1,10 @@
|
|||||||
# GeoIntel
|
# GeoIntel
|
||||||
|
|
||||||
|
GeoIntel ondersteunt een gesynchroniseerde tweeschermwerkruimte: de kaart blijft
|
||||||
|
maximaal zichtbaar op het hoofdscherm terwijl analyses, kwaliteitsbewijs en
|
||||||
|
resultaten live in een tweede venster staan. Zie
|
||||||
|
[`docs/DUAL_DISPLAY_WORKFLOW.md`](docs/DUAL_DISPLAY_WORKFLOW.md).
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<img src="frontend/public/geointel-icon.png" width="92" alt="GeoIntel logo">
|
<img src="frontend/public/geointel-icon.png" width="92" alt="GeoIntel logo">
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,3 +1,23 @@
|
|||||||
|
## 2026-08-21 - Gesynchroniseerde tweeschermwerkruimte
|
||||||
|
|
||||||
|
### Opgeleverd
|
||||||
|
|
||||||
|
- Een nieuwe knop in de werkbalk opent een afzonderlijke analyseconsole voor
|
||||||
|
een tweede monitor.
|
||||||
|
- De bestaande resultatenlade wordt via een React-portal verplaatst. Kaart en
|
||||||
|
console delen daardoor exact dezelfde selectie-, analyse- en QA-state zonder
|
||||||
|
dubbele API-aanvragen.
|
||||||
|
- Het venster onthoudt positie en afmetingen, meldt popupblokkering en valt bij
|
||||||
|
sluiten zonder dataverlies terug naar de gewone éénschermlade.
|
||||||
|
- Operator en demo krijgen dezelfde functie; bestaande rechten wijzigen niet.
|
||||||
|
|
||||||
|
### Verificatie
|
||||||
|
|
||||||
|
- TypeScript typecheck, gerichte Vitest-regressies en productiebuild slagen.
|
||||||
|
- De live gastwerkstroom is lokaal tegen de Tower-backend doorlopen: tweede
|
||||||
|
scherm openen, gebied selecteren, thema analyseren en dezelfde resultaten na
|
||||||
|
sluiten terugvinden in de hoofdinterface.
|
||||||
|
|
||||||
## 2026-08-10 - V74 root-cause remediation en authority-first uitvoer
|
## 2026-08-10 - V74 root-cause remediation en authority-first uitvoer
|
||||||
|
|
||||||
### Opgeleverd
|
### Opgeleverd
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
# Tweeschermwerkruimte
|
||||||
|
|
||||||
|
GeoIntel kan de kaart en de analyseconsole over twee browservensters verdelen
|
||||||
|
zonder een tweede sessie of dubbele analyse te starten.
|
||||||
|
|
||||||
|
## Gebruik
|
||||||
|
|
||||||
|
1. Open de kaartwerkruimte.
|
||||||
|
2. Kies **Open tweede scherm** in de bovenbalk.
|
||||||
|
3. Verplaats de analyseconsole naar het tweede beeldscherm wanneer de browser
|
||||||
|
dit niet automatisch doet.
|
||||||
|
4. Selecteer gebieden, bronnen en modellen op het hoofdscherm. Voortgang,
|
||||||
|
resultaten, GRB-controle, grafieken en downloads verschijnen live in de
|
||||||
|
analyseconsole.
|
||||||
|
5. Gebruik **Sluit console** in het tweede venster of **Sluit tweede scherm**
|
||||||
|
in de bovenbalk om terug te keren naar de normale resultatenlade.
|
||||||
|
|
||||||
|
De laatst gebruikte vensterpositie en -afmetingen worden lokaal onthouden. Als
|
||||||
|
de browser pop-ups blokkeert, toont GeoIntel een concrete melding. Er gaat geen
|
||||||
|
analyse verloren wanneer het tweede venster wordt gesloten.
|
||||||
|
|
||||||
|
## Technisch contract
|
||||||
|
|
||||||
|
- De console is een React-portal van dezelfde actieve werkruimte.
|
||||||
|
- Er ontstaat geen tweede API-sessie, modelrun of onafhankelijke statekopie.
|
||||||
|
- Kaartselectie, modelkeuze, laadstatus, QA-bewijs en resultaten blijven daarom
|
||||||
|
synchroon.
|
||||||
|
- De normale éénscherminterface blijft de fail-safe fallback.
|
||||||
|
- De functie is beschikbaar voor operator- en demosessies; bestaande
|
||||||
|
autorisatiegrenzen veranderen niet.
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
# GeoIntel TODO
|
# GeoIntel TODO
|
||||||
|
|
||||||
|
## Tweeschermwerkruimte (2026-08-21)
|
||||||
|
|
||||||
|
- [x] Voeg een gesynchroniseerde analyseconsole voor een tweede beeldscherm toe.
|
||||||
|
- [x] Verplaats dezelfde React-resultaatstate zonder dubbele API-calls of runs.
|
||||||
|
- [x] Onthoud vensterpositie en herstel veilig naar de éénschermlade.
|
||||||
|
- [x] Voorzie popupfoutmelding, toetsenbordlabels en operator-/demopariteit.
|
||||||
|
|
||||||
## V74 authority-first gebouwresultaten (2026-08-10)
|
## V74 authority-first gebouwresultaten (2026-08-10)
|
||||||
|
|
||||||
- [x] Voer een V72-foutanalyse, hard-negative proposal-filter en rehearsal
|
- [x] Voer een V72-foutanalyse, hard-negative proposal-filter en rehearsal
|
||||||
|
|||||||
+26
-2
@@ -1,5 +1,5 @@
|
|||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { CircleAlert, LogOut, ShieldCheck, UserRound } from 'lucide-react'
|
import { CircleAlert, LogOut, MonitorUp, ShieldCheck, UserRound } from 'lucide-react'
|
||||||
import '@fontsource/manrope/latin-500.css'
|
import '@fontsource/manrope/latin-500.css'
|
||||||
import '@fontsource/manrope/latin-600.css'
|
import '@fontsource/manrope/latin-600.css'
|
||||||
import '@fontsource/manrope/latin-700.css'
|
import '@fontsource/manrope/latin-700.css'
|
||||||
@@ -37,6 +37,7 @@ import {
|
|||||||
type WorkspaceNavigationItem,
|
type WorkspaceNavigationItem,
|
||||||
} from './components/shell/WorkbenchNavigation'
|
} from './components/shell/WorkbenchNavigation'
|
||||||
import { WorkspaceSignal } from './components/shell/WorkspaceSignal'
|
import { WorkspaceSignal } from './components/shell/WorkspaceSignal'
|
||||||
|
import { useSecondaryDisplay } from './components/shell/SecondaryDisplay'
|
||||||
import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
|
import { useChangeDetectionWorkflow } from './hooks/useChangeDetectionWorkflow'
|
||||||
import { useDemoWorkflow } from './hooks/useDemoWorkflow'
|
import { useDemoWorkflow } from './hooks/useDemoWorkflow'
|
||||||
import { useCoverageResolver } from './hooks/useCoverageResolver'
|
import { useCoverageResolver } from './hooks/useCoverageResolver'
|
||||||
@@ -119,6 +120,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
|
|||||||
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
|
const [activeWorkspace, setActiveWorkspace] = useState<WorkspaceKey>('map')
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(false)
|
const [inspectorOpen, setInspectorOpen] = useState(false)
|
||||||
const [guestDemoReady, setGuestDemoReady] = useState(!isGuest)
|
const [guestDemoReady, setGuestDemoReady] = useState(!isGuest)
|
||||||
|
const secondaryDisplay = useSecondaryDisplay()
|
||||||
const guestDemoStartedRef = useRef(false)
|
const guestDemoStartedRef = useRef(false)
|
||||||
const visibleWorkspaceItems = useMemo(
|
const visibleWorkspaceItems = useMemo(
|
||||||
() => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems,
|
() => isGuest ? workspaceNavItems.filter((item) => guestWorkspaceKeys.has(item.key)) : workspaceNavItems,
|
||||||
@@ -830,7 +832,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
|
|||||||
: 'Geen actieve laag'
|
: 'Geen actieve laag'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={isGuest ? "app-shell workbench-shell workbench-shell-guest" : "app-shell workbench-shell"}>
|
<div className={`${isGuest ? 'app-shell workbench-shell workbench-shell-guest' : 'app-shell workbench-shell'}${secondaryDisplay.isOpen ? ' workbench-shell-dual-display' : ''}`}>
|
||||||
<a
|
<a
|
||||||
className="skip-link"
|
className="skip-link"
|
||||||
href="#workspace-main"
|
href="#workspace-main"
|
||||||
@@ -887,6 +889,20 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
|
|||||||
<span aria-hidden="true" />
|
<span aria-hidden="true" />
|
||||||
<strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong>
|
<strong>{workspaceDataLoading ? 'Laden' : errorMessage ? 'Aandacht' : 'Gereed'}</strong>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={secondaryDisplay.isOpen ? 'secondary-display-launch secondary-display-launch-active' : 'secondary-display-launch'}
|
||||||
|
aria-pressed={secondaryDisplay.isOpen}
|
||||||
|
aria-describedby={secondaryDisplay.error ? 'secondary-display-error' : undefined}
|
||||||
|
onClick={() => {
|
||||||
|
setActiveWorkspace('map')
|
||||||
|
if (secondaryDisplay.isOpen) secondaryDisplay.close()
|
||||||
|
else secondaryDisplay.open()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<MonitorUp aria-hidden="true" />
|
||||||
|
<span>{secondaryDisplay.isOpen ? 'Sluit tweede scherm' : 'Open tweede scherm'}</span>
|
||||||
|
</button>
|
||||||
{username || isGuest ? (
|
{username || isGuest ? (
|
||||||
<div
|
<div
|
||||||
className={isGuest ? 'context-account context-account-guest' : 'context-account'}
|
className={isGuest ? 'context-account context-account-guest' : 'context-account'}
|
||||||
@@ -902,6 +918,13 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
|
|||||||
) : null}
|
) : null}
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
{secondaryDisplay.error ? (
|
||||||
|
<div className="secondary-display-error" id="secondary-display-error" role="alert">
|
||||||
|
<CircleAlert aria-hidden="true" />
|
||||||
|
<span>{secondaryDisplay.error}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{isGuest ? (
|
{isGuest ? (
|
||||||
<div className="guest-mode-banner" role="status">
|
<div className="guest-mode-banner" role="status">
|
||||||
<ShieldCheck aria-hidden="true" />
|
<ShieldCheck aria-hidden="true" />
|
||||||
@@ -1031,6 +1054,7 @@ function WorkbenchApp({ username, accessMode, loggingOut, onLogout }: WorkbenchA
|
|||||||
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
|
<div className="workspace-persistent-map" hidden={activeWorkspace !== 'map'}>
|
||||||
<MapWorkspace
|
<MapWorkspace
|
||||||
readOnly={false}
|
readOnly={false}
|
||||||
|
secondaryResultsContainer={secondaryDisplay.container}
|
||||||
selectedProjectId={selectedProjectId}
|
selectedProjectId={selectedProjectId}
|
||||||
projects={projects}
|
projects={projects}
|
||||||
areas={areas}
|
areas={areas}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { getDatasetDisplayName, getDatasetSourceDisplayName } from '../../lib/da
|
|||||||
import { TemporalTrendChart } from './TemporalTrendChart'
|
import { TemporalTrendChart } from './TemporalTrendChart'
|
||||||
import { MunicipalitySearch } from './MunicipalitySearch'
|
import { MunicipalitySearch } from './MunicipalitySearch'
|
||||||
import { LiveAnalysisJourney } from './LiveAnalysisJourney'
|
import { LiveAnalysisJourney } from './LiveAnalysisJourney'
|
||||||
|
import { SecondaryDisplayTarget } from '../shell/SecondaryDisplay'
|
||||||
import { terrainImageUrl } from '../../lib/terrainImage'
|
import { terrainImageUrl } from '../../lib/terrainImage'
|
||||||
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
import { floodHazardImageUrl } from '../../lib/floodHazardImage'
|
||||||
import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster'
|
import { thematicRasterImageUrl, walousRasterImageUrl } from '../../lib/thematicRaster'
|
||||||
@@ -645,6 +646,7 @@ function floodScenarioLabel(dataset: DatasetCreateResponse): string {
|
|||||||
|
|
||||||
interface MapWorkspaceProps {
|
interface MapWorkspaceProps {
|
||||||
readOnly?: boolean
|
readOnly?: boolean
|
||||||
|
secondaryResultsContainer?: HTMLElement | null
|
||||||
selectedProjectId: string | null
|
selectedProjectId: string | null
|
||||||
projects: ProjectRead[]
|
projects: ProjectRead[]
|
||||||
areas: AreaRead[]
|
areas: AreaRead[]
|
||||||
@@ -740,6 +742,7 @@ interface MapWorkspaceProps {
|
|||||||
|
|
||||||
export function MapWorkspace({
|
export function MapWorkspace({
|
||||||
readOnly = false,
|
readOnly = false,
|
||||||
|
secondaryResultsContainer = null,
|
||||||
selectedProjectId,
|
selectedProjectId,
|
||||||
projects,
|
projects,
|
||||||
areas,
|
areas,
|
||||||
@@ -2689,25 +2692,28 @@ export function MapWorkspace({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
{secondaryResultsContainer ? null : (
|
||||||
className={resultsPanelOpen ? 'geo-results-toggle geo-results-toggle-open' : 'geo-results-toggle'}
|
<button
|
||||||
type="button"
|
className={resultsPanelOpen ? 'geo-results-toggle geo-results-toggle-open' : 'geo-results-toggle'}
|
||||||
aria-controls="geo-explorer-results"
|
type="button"
|
||||||
aria-expanded={resultsPanelOpen}
|
aria-controls="geo-explorer-results"
|
||||||
aria-label={resultsPanelOpen ? 'Sluit inzichten' : 'Open inzichten'}
|
aria-expanded={resultsPanelOpen}
|
||||||
onClick={() => setResultsPanelOpen((open) => !open)}
|
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>
|
{resultsPanelOpen ? <ChevronRight aria-hidden="true" /> : <ChevronLeft aria-hidden="true" />}
|
||||||
</button>
|
<span>{resultsPanelOpen ? 'Sluit inzichten' : 'Inzichten'}</span>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
<aside
|
<SecondaryDisplayTarget container={secondaryResultsContainer}>
|
||||||
id="geo-explorer-results"
|
<aside
|
||||||
className={resultsPanelOpen ? 'geo-results-panel geo-results-panel-open' : 'geo-results-panel'}
|
id={secondaryResultsContainer ? 'geo-secondary-results' : 'geo-explorer-results'}
|
||||||
aria-label="Gebiedsanalyse"
|
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-live="polite"
|
aria-label="Gebiedsanalyse"
|
||||||
aria-hidden={!resultsPanelOpen}
|
aria-live="polite"
|
||||||
>
|
aria-hidden={secondaryResultsContainer ? false : !resultsPanelOpen}
|
||||||
|
>
|
||||||
<div className="geo-panel-heading">
|
<div className="geo-panel-heading">
|
||||||
<div>
|
<div>
|
||||||
<h3>Inzichten</h3>
|
<h3>Inzichten</h3>
|
||||||
@@ -3031,7 +3037,8 @@ export function MapWorkspace({
|
|||||||
: ''}
|
: ''}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</aside>
|
</aside>
|
||||||
|
</SecondaryDisplayTarget>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<footer className="geo-explorer-footer">
|
<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(',')
|
||||||
|
}
|
||||||
@@ -1990,6 +1990,223 @@ button.overview-command-card { cursor: pointer; }
|
|||||||
.context-health { min-width: auto; border: 0; padding-inline: 0.45rem; background: transparent; box-shadow: none; }
|
.context-health { min-width: auto; border: 0; padding-inline: 0.45rem; background: transparent; box-shadow: none; }
|
||||||
.context-account { border-color: var(--gi-line); box-shadow: none; }
|
.context-account { border-color: var(--gi-line); box-shadow: none; }
|
||||||
|
|
||||||
|
/* Synchronized dual-display workbench */
|
||||||
|
.secondary-display-launch {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
gap: 0.45rem;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-height: 2.35rem;
|
||||||
|
border: 1px solid var(--gi-line);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.45rem 0.75rem;
|
||||||
|
background: var(--gi-surface);
|
||||||
|
color: var(--gi-ink-700);
|
||||||
|
font: inherit;
|
||||||
|
font-size: var(--gi-text-2xs);
|
||||||
|
font-weight: 750;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color var(--gi-dur-2) var(--gi-ease), background var(--gi-dur-2) var(--gi-ease), color var(--gi-dur-2) var(--gi-ease);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-launch svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-launch:hover,
|
||||||
|
.secondary-display-launch:focus-visible {
|
||||||
|
border-color: var(--gi-brand-400);
|
||||||
|
background: var(--gi-brand-50);
|
||||||
|
color: var(--gi-brand-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-launch-active {
|
||||||
|
border-color: var(--gi-brand-600);
|
||||||
|
background: var(--gi-brand-800);
|
||||||
|
color: #fff;
|
||||||
|
box-shadow: 0 0 0 3px color-mix(in srgb, var(--gi-brand-500) 14%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-launch-active:hover,
|
||||||
|
.secondary-display-launch-active:focus-visible {
|
||||||
|
border-color: var(--gi-brand-700);
|
||||||
|
background: var(--gi-brand-700);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-error {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.55rem;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid #f2c8c2;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
background: #fff4f2;
|
||||||
|
color: #8b2f25;
|
||||||
|
font-size: var(--gi-text-xs);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-error svg {
|
||||||
|
width: 1rem;
|
||||||
|
height: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-body {
|
||||||
|
min-width: 420px;
|
||||||
|
min-height: 100dvh;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--gi-canvas);
|
||||||
|
color: var(--gi-ink-900);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-shell {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
width: 100vw;
|
||||||
|
height: 100dvh;
|
||||||
|
background: var(--gi-canvas);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-header {
|
||||||
|
position: relative;
|
||||||
|
z-index: 20;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto auto;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
min-height: 4.5rem;
|
||||||
|
border-bottom: 1px solid var(--gi-line);
|
||||||
|
padding: 0.65rem 0.85rem;
|
||||||
|
background: color-mix(in srgb, var(--gi-surface) 96%, transparent);
|
||||||
|
box-shadow: 0 8px 24px rgba(5, 46, 43, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-brand {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.65rem;
|
||||||
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-brand img {
|
||||||
|
width: 2.35rem;
|
||||||
|
height: 2.35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-brand span {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-brand small {
|
||||||
|
color: var(--gi-brand-600);
|
||||||
|
font-size: 0.58rem;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.09em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-brand strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: var(--gi-font-display);
|
||||||
|
font-size: 1rem;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-status {
|
||||||
|
display: inline-flex;
|
||||||
|
gap: 0.4rem;
|
||||||
|
align-items: center;
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
background: var(--gi-brand-50);
|
||||||
|
color: var(--gi-brand-800);
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-status i {
|
||||||
|
width: 0.48rem;
|
||||||
|
height: 0.48rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--gi-accent-500);
|
||||||
|
box-shadow: 0 0 0 4px color-mix(in srgb, var(--gi-accent-500) 15%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-close {
|
||||||
|
min-height: 2.3rem;
|
||||||
|
border: 1px solid var(--gi-line);
|
||||||
|
border-radius: var(--gi-radius-md);
|
||||||
|
padding: 0.45rem 0.65rem;
|
||||||
|
background: var(--gi-surface);
|
||||||
|
color: var(--gi-ink-700);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 0.68rem;
|
||||||
|
font-weight: 700;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-close:hover,
|
||||||
|
.secondary-display-close:focus-visible {
|
||||||
|
border-color: var(--gi-brand-400);
|
||||||
|
color: var(--gi-brand-800);
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-content {
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
padding: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-content > .geo-results-panel,
|
||||||
|
.secondary-display-content > .geo-results-panel.geo-results-panel-open {
|
||||||
|
position: static !important;
|
||||||
|
inset: auto !important;
|
||||||
|
display: flex;
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: none !important;
|
||||||
|
height: auto !important;
|
||||||
|
max-height: none !important;
|
||||||
|
min-height: calc(100dvh - 6.2rem);
|
||||||
|
overflow: visible !important;
|
||||||
|
border: 1px solid var(--gi-line) !important;
|
||||||
|
border-radius: var(--gi-radius-lg) !important;
|
||||||
|
background: var(--gi-surface) !important;
|
||||||
|
box-shadow: var(--gi-shadow-sm) !important;
|
||||||
|
visibility: visible !important;
|
||||||
|
opacity: 1 !important;
|
||||||
|
pointer-events: auto !important;
|
||||||
|
transform: none !important;
|
||||||
|
animation: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.secondary-display-content .geo-results-panel > .geo-panel-heading {
|
||||||
|
position: sticky;
|
||||||
|
z-index: 4;
|
||||||
|
top: -0.85rem;
|
||||||
|
border-bottom: 1px solid var(--gi-line);
|
||||||
|
background: color-mix(in srgb, var(--gi-surface) 96%, transparent);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.workbench-shell-dual-display .geo-map-stage {
|
||||||
|
border-right-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 820px) {
|
||||||
|
.secondary-display-launch span { display: none; }
|
||||||
|
.secondary-display-launch { width: 2.5rem; padding-inline: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 560px) {
|
||||||
|
.secondary-display-header { grid-template-columns: minmax(0, 1fr) auto; }
|
||||||
|
.secondary-display-status { display: none; }
|
||||||
|
}
|
||||||
|
|
||||||
.geo-explorer-layout,
|
.geo-explorer-layout,
|
||||||
.workbench-shell-guest .geo-explorer-layout {
|
.workbench-shell-guest .geo-explorer-layout {
|
||||||
grid-template-columns: clamp(18rem, 18vw, 22rem) minmax(0, 1fr);
|
grid-template-columns: clamp(18rem, 18vw, 22rem) minmax(0, 1fr);
|
||||||
@@ -2092,4 +2309,4 @@ button.overview-command-card { cursor: pointer; }
|
|||||||
.geo-explorer-layout, .workbench-shell-guest .geo-explorer-layout { grid-template-rows: 15rem minmax(25rem, 62vh); }
|
.geo-explorer-layout, .workbench-shell-guest .geo-explorer-layout { grid-template-rows: 15rem minmax(25rem, 62vh); }
|
||||||
.geo-theme-panel { max-height: 15rem; }
|
.geo-theme-panel { max-height: 15rem; }
|
||||||
.geo-theme-list { max-height: 7.2rem; min-height: 4.75rem; }
|
.geo-theme-list { max-height: 7.2rem; min-height: 4.75rem; }
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user