HeatTransPlan / frontend /src /components /map /MapViewer.tsx
drzg15's picture
repairing the circle dent
b187967
import React, { useEffect, useMemo, useRef, useState } from 'react';
import {
MapContainer,
TileLayer,
Marker,
Polyline,
Rectangle,
useMap,
useMapEvents,
Pane,
} from 'react-leaflet';
import L from 'leaflet';
import 'leaflet/dist/leaflet.css';
import type { ProcessNode } from '../../types/process';
import { extractStreamInfo } from '../../utils/streamInfo';
import './MapViewer.css';
// Fix missing Leaflet default marker icons
import markerIcon2x from 'leaflet/dist/images/marker-icon-2x.png';
import markerIcon from 'leaflet/dist/images/marker-icon.png';
import markerShadow from 'leaflet/dist/images/marker-shadow.png';
// @ts-ignore
delete (L.Icon.Default.prototype as any)._getIconUrl;
L.Icon.Default.mergeOptions({
iconRetinaUrl: markerIcon2x,
iconUrl: markerIcon,
shadowUrl: markerShadow,
});
interface GroupCoords {
lat?: string | number | null;
lon?: string | number | null;
hours?: string;
box_scale?: string | number;
}
interface Props {
center: [number, number];
zoom: number;
locked: boolean;
processes: ProcessNode[];
groups: number[][];
groupNames: string[];
groupCoordinates: Record<string, GroupCoords>;
baseTile: string;
onClick: (lat: number, lon: number) => void;
onMoveEnd: (center: [number, number], zoom: number) => void;
subprocessMapExpanded: Record<number, boolean>;
childMapExpanded: Record<number, boolean>;
onProcessesChange?: (processes: ProcessNode[]) => void;
onGroupCoordinatesChange?: (coords: Record<string, GroupCoords>) => void;
onElementDoubleClick?: (type: 'group' | 'sub' | 'child' | 'stream', id: any, subId?: any) => void;
height?: string | number;
// Selection
selectedStreams?: Record<string, boolean>;
onProcessesSelect?: (pIdxs: number[], val: boolean) => void;
onSelectionToggle?: (pIdx: number, si: number) => void;
selectionActive?: boolean;
className?: string;
allowMultiMove?: boolean;
}
/**
* BoxSelector: Handles Click-and-Drag area selection on the map.
* Re-implemented with direct DOM events for maximum reliability on locked maps.
*/
function BoxSelector({ onSelect, active }: {
onSelect: (bounds: L.LatLngBounds) => void,
active: boolean
}) {
const map = useMap();
const [visualBounds, setVisualBounds] = useState<L.LatLngBounds | null>(null);
const startLatLngRef = useRef<L.LatLng | null>(null);
useEffect(() => {
if (!active) {
setVisualBounds(null);
startLatLngRef.current = null;
return;
}
const container = map.getContainer();
const onDown = (e: MouseEvent) => {
// 1. Only left click
if (e.button !== 0) return;
// 2. Ignore UI
const target = e.target as HTMLElement;
if (target.closest('.leaflet-control') || target.closest('.pa-map-toolbar') || target.closest('.leaflet-marker-icon')) {
return;
}
// 3. Start selection
const latlng = map.mouseEventToLatLng(e);
startLatLngRef.current = latlng;
setVisualBounds(null);
// Prevent map panning
map.dragging.disable();
};
const onMove = (e: MouseEvent) => {
if (!startLatLngRef.current) return;
const currentLatLng = map.mouseEventToLatLng(e);
const bounds = L.latLngBounds(startLatLngRef.current, currentLatLng);
setVisualBounds(bounds);
};
const onUp = (e: MouseEvent) => {
if (!startLatLngRef.current) {
map.dragging.enable();
return;
}
const currentLatLng = map.mouseEventToLatLng(e);
const bounds = L.latLngBounds(startLatLngRef.current, currentLatLng);
// Clean up state immediately
startLatLngRef.current = null;
setVisualBounds(null);
map.dragging.enable();
// Trigger selection if meaningful distance
if (bounds.getNorthEast().distanceTo(bounds.getSouthWest()) > 1.0) {
onSelect(bounds);
}
};
L.DomEvent.on(container, 'mousedown', onDown as any);
L.DomEvent.on(window as any, 'mousemove', onMove as any);
L.DomEvent.on(window as any, 'mouseup', onUp as any);
return () => {
L.DomEvent.off(container, 'mousedown', onDown as any);
L.DomEvent.off(window as any, 'mousemove', onMove as any);
L.DomEvent.off(window as any, 'mouseup', onUp as any);
map.dragging.enable();
};
}, [active, map, onSelect]);
if (!visualBounds) return null;
return (
<Rectangle
bounds={visualBounds}
pathOptions={{ color: '#3498db', weight: 2, dashArray: '6, 8', fillOpacity: 0.15, interactive: false }}
/>
);
}
const TILE_URLS: Record<string, string> = {
OpenStreetMap: 'https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png',
Positron:
'https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png',
Satellite:
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
};
function createDivIcon(
_emoji: string,
label: string,
_color: string,
scale: number = 1.0,
isSelected: boolean = true,
isMultiSelected: boolean = false,
markerType: string = '',
markerId: string = ''
) {
const fs = Math.max(10, Math.round(10 * scale));
const px = Math.max(4, Math.round(8 * scale));
const py = Math.max(2, Math.round(4 * scale));
const opacity = isSelected ? 1.0 : 0.4;
const borderColor = isMultiSelected ? 'var(--warning)' : (isSelected ? '#1b5e20' : '#1b5e20');
const bg = isMultiSelected ? 'var(--brand-magenta)' : (isSelected ? '#2e7d32' : '#1b5e20');
const borderWeight = isMultiSelected ? '3px' : '1px';
const textColor = '#ffffff'; // Always white for green markers for best contrast
const avgCharWidth = fs * 0.62;
const width = Math.round(Math.max(20, (label.length * avgCharWidth) + (px * 2) + 2));
const height = Math.round(fs + (py * 2) + 2);
const dataAttrs = markerType ? `data-marker-type="${markerType}" data-marker-id="${markerId}"` : '';
return L.divIcon({
className: 'custom-marker',
html: `<div class="draggable-marker" ${dataAttrs} style="background:${bg};color:${textColor};padding:${py}px ${px}px;border-radius:4px;font-size:${fs}px;font-weight:600;white-space:nowrap;width:${width}px;height:${height}px;text-align:center;display:flex;align-items:center;justify-content:center;border:${borderWeight} solid ${borderColor};box-shadow:var(--shadow-sm);opacity:${opacity};transition:background 0.23s,opacity 0.23s;cursor:grab;user-select:none">
${label}
</div>`,
iconSize: [width, height],
iconAnchor: [width / 2, height / 2],
});
}
function MapController({ center, zoom, locked }: { center: [number, number]; zoom: number; locked: boolean }) {
const map = useMap();
useEffect(() => {
if (locked) {
map.setView(center, zoom, { animate: false });
map.dragging.disable();
map.scrollWheelZoom.disable();
map.doubleClickZoom.disable();
map.boxZoom.disable();
map.keyboard.disable();
if ((map as any).touchZoom) (map as any).touchZoom.disable();
if ((map as any).tap) (map as any).tap.disable();
} else {
map.dragging.enable();
map.scrollWheelZoom.enable();
map.doubleClickZoom.enable();
map.boxZoom.enable();
map.keyboard.enable();
if ((map as any).touchZoom) (map as any).touchZoom.enable();
if ((map as any).tap) (map as any).tap.enable();
}
}, [center, zoom, locked, map]);
return null;
}
function MapMoveHandler({
onMoveEnd,
locked,
}: {
onMoveEnd: (center: [number, number], zoom: number) => void;
locked: boolean;
}) {
useMapEvents({
moveend(e) {
if (locked) return;
const map = e.target;
const c = map.getCenter();
onMoveEnd([c.lat, c.lng], map.getZoom());
},
});
return null;
}
function MapFullscreenResizer({ active }: { active: boolean }) {
const map = useMap();
useEffect(() => {
map.invalidateSize();
const delays = [50, 200, 500];
const timers = delays.map(d => setTimeout(() => map.invalidateSize(), d));
return () => timers.forEach(t => clearTimeout(t));
}, [active, map]);
return null;
}
function MapClickHandler({ onClick }: { onClick?: (lat: number, lon: number) => void }) {
useMapEvents({
click(e) {
if (onClick) onClick(e.latlng.lat, e.latlng.lng);
},
});
return null;
}
/**
* Called once on mount: invalidates the Leaflet container size, waits a tick
* for Leaflet to recalculate its internal pixel grid, then snaps the view to
* the correct center/zoom. invalidateSize and setView MUST be in separate
* timeouts — calling them together means setView runs before Leaflet has
* finished updating its size math, causing the "cut-off corner" bug.
*/
function MapMountFitter({
center,
zoom,
}: {
center: [number, number];
zoom: number;
}) {
const map = useMap();
useEffect(() => {
const timers: ReturnType<typeof setTimeout>[] = [];
// Step 1: force Leaflet to measure the real container size
const invalidate = () => map.invalidateSize({ animate: false });
// Step 2: after size is known, snap to correct center+zoom
const refit = () => map.setView(center, zoom, { animate: false });
timers.push(setTimeout(invalidate, 0));
timers.push(setTimeout(refit, 50));
timers.push(setTimeout(invalidate, 100));
timers.push(setTimeout(refit, 150));
timers.push(setTimeout(invalidate, 300));
timers.push(setTimeout(refit, 400));
return () => timers.forEach(t => clearTimeout(t));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return null;
}
function SubprocessCanvasOverlay({ active }: { active: boolean }) {
const map = useMap();
const [bounds, setBounds] = useState<L.LatLngBounds | null>(null);
useEffect(() => {
if (active) {
const tb = map.getBounds();
const n = tb.getNorth();
const s = tb.getSouth();
const e = tb.getEast();
const w = tb.getWest();
const latDiff = n - s;
const lngDiff = e - w;
// Expand canvas to cover about 90% of the view area (5% margins)
const newBounds = L.latLngBounds(
L.latLng(s + latDiff * 0.05, w + lngDiff * 0.05),
L.latLng(n - latDiff * 0.05, e - lngDiff * 0.05)
);
setBounds(newBounds);
} else {
setBounds(null);
}
}, [active, map]);
if (!active || !bounds) return null;
return (
<Rectangle
bounds={bounds}
pane="canvasPane"
pathOptions={{ color: 'var(--border)', weight: 1, fillColor: 'var(--surface)', fillOpacity: 0.96 }}
/>
);
}
// ─── Stream Circles Overlay ─────────────────────────────────────────────────
// Draws SVG bubble circles on the map above each process group, matching the
// Streamlit app: circle SIZE = Q heat power (kW, globally min-max scaled),
// COLOR = continuous blue→red gradient based on global temperature range.
interface StreamCirclesOverlayProps {
processes: ProcessNode[];
groups: number[][];
groupNames: string[];
groupCoordinates: Record<string, GroupCoords>;
onElementDoubleClick?: (type: 'group' | 'sub' | 'child' | 'stream', id: any, subId?: any) => void;
selectedStreams?: Record<string, boolean>;
onSelectionToggle?: (pIdx: number, si: number) => void;
locked?: boolean;
onDragEnd?: (e: L.LeafletEvent, type: 'group' | 'sub' | 'child', id: string) => void;
renderingMode?: 'default' | 'children';
childMapExpanded?: Record<number, boolean>;
}
function streamColor(isHot: boolean, t: number, isSelected: boolean): { fill: string; stroke: string } {
if (!isSelected) {
return {
fill: 'rgba(180,180,180,0.3)',
stroke: 'rgba(150,150,150,0.4)',
};
}
if (isHot) {
const r = 255;
const g = Math.round(120 - t * 120);
const b = Math.round(120 - t * 120);
return {
fill: `rgba(${r},${g},${b},0.88)`,
stroke: `rgba(${Math.round(r * 0.6)},0,0,0.95)`,
};
} else {
const r = Math.round(100 - t * 80);
const g = Math.round(150 - t * 120);
const b = 255;
return {
fill: `rgba(${r},${g},${b},0.88)`,
stroke: `rgba(0,${Math.round(g * 0.4)},${Math.round(b * 0.6)},0.95)`,
};
}
}
function StreamCirclesOverlay({
processes,
groups,
groupNames: _groupNames,
groupCoordinates,
onElementDoubleClick,
selectedStreams,
onSelectionToggle,
locked = false,
onDragEnd,
renderingMode = 'default',
childMapExpanded,
}: StreamCirclesOverlayProps) {
interface StreamBubble {
name: string;
subprocess: string;
tin: number | null;
tout: number | null;
Q: number | null;
pIdx: number;
ci?: number;
si: number;
}
interface GroupBubbles {
gIdx: number;
lat: number;
lon: number;
streams: StreamBubble[];
}
const groupBubbles = useMemo<GroupBubbles[]>(() => {
const result: GroupBubbles[] = [];
if (renderingMode === 'children' && childMapExpanded) {
processes.forEach((sub, si) => {
if (!childMapExpanded[si]) return;
(sub.children || []).forEach((child, ci) => {
const lat = parseFloat(String(child.lat));
const lon = parseFloat(String(child.lon));
if (isNaN(lat) || isNaN(lon)) return;
const streams: StreamBubble[] = [];
(child.streams || []).forEach((s, sidx) => {
const info = extractStreamInfo(s as Record<string, any>);
streams.push({
name: s.name || 'Stream',
subprocess: child.name,
tin: info.tin, tout: info.tout, Q: info.Q,
pIdx: si, ci: ci, si: sidx
});
});
if (streams.length > 0) {
result.push({ gIdx: -1, lat, lon, streams });
}
});
});
} else {
groups.forEach((subIdxs, gIdx) => {
const gCoords = groupCoordinates[gIdx];
if (!gCoords) return;
const lat = parseFloat(String(gCoords.lat ?? ''));
const lon = parseFloat(String(gCoords.lon ?? ''));
if (isNaN(lat) || isNaN(lon)) return;
const streams: StreamBubble[] = [];
subIdxs.forEach((si) => {
const p = processes[si];
if (!p) return;
(p.streams || []).forEach((s, sidx) => {
const info = extractStreamInfo(s as Record<string, any>);
streams.push({
name: s.name || 'Stream',
subprocess: p.name,
tin: info.tin, tout: info.tout, Q: info.Q,
pIdx: si, si: sidx
});
});
});
if (streams.length > 0) {
result.push({ gIdx, lat, lon, streams });
}
});
}
return result;
}, [processes, groups, groupCoordinates, renderingMode, childMapExpanded]);
const { qMin, qRange } = useMemo(() => {
const qs = groupBubbles.flatMap((gb) => gb.streams.map((s) => s.Q ?? 0)).filter((q) => q > 0);
const mn = qs.length ? Math.min(...qs) : 0;
const mx = qs.length ? Math.max(...qs) : 1;
return { qMin: mn, qRange: mx === mn ? 1 : mx - mn };
}, [groupBubbles]);
const { hotMin, hotRange, coldMin, coldRange } = useMemo(() => {
const hotTemps: number[] = [];
const coldTemps: number[] = [];
groupBubbles.forEach((gb) => {
gb.streams.forEach((s) => {
if (s.tin != null && s.tout != null) {
const maxT = Math.max(s.tin, s.tout);
if (s.tin > s.tout) hotTemps.push(maxT);
else coldTemps.push(maxT);
}
});
});
const hMin = hotTemps.length ? Math.min(...hotTemps) : 0;
const hMax = hotTemps.length ? Math.max(...hotTemps) : 200;
const cMin = coldTemps.length ? Math.min(...coldTemps) : 0;
const cMax = coldTemps.length ? Math.max(...coldTemps) : 200;
return {
hotMin: hMin, hotRange: hMax === hMin ? 1 : hMax - hMin,
coldMin: cMin, coldRange: cMax === cMin ? 1 : cMax - cMin,
};
}, [groupBubbles]);
const streamColorFor = (tin: number | null, tout: number | null, isSelected: boolean) => {
if (tin == null || tout == null) return { fill: 'rgba(150,150,150,0.5)', stroke: '#999' };
const isHot = tin > tout;
const maxT = Math.max(tin, tout);
const t = isHot
? Math.max(0, Math.min(1, (maxT - hotMin) / hotRange))
: Math.max(0, Math.min(1, (maxT - coldMin) / coldRange));
return streamColor(isHot, t, isSelected);
};
const R_MIN = 7;
const R_MAX = 22;
const getRadius = (Q: number | null) => {
if (!Q || Q <= 0) return R_MIN;
return R_MIN + Math.round(((Q - qMin) / qRange) * (R_MAX - R_MIN));
};
return (
<>
{groupBubbles.map(({ gIdx, lat, lon, streams }) => {
const SPACING = 6;
const radii = streams.map((s) => getRadius(s.Q));
const maxR = Math.max(...radii, R_MIN);
const svgH = maxR * 2 + 4;
let cx = (radii[0] ?? R_MIN) + 2;
const circleParts: string[] = [];
streams.forEach((s, idx) => {
const r = radii[idx];
const isSelected = s.ci !== undefined
? selectedStreams?.[`stream_${s.pIdx}_${s.ci}_${s.si}`] !== false
: selectedStreams?.[`stream_${s.pIdx}_${s.si}`] !== false;
const { fill, stroke } = streamColorFor(s.tin, s.tout, isSelected);
circleParts.push(
`<circle
cx="${cx}"
cy="${maxR + 2}"
r="${r}"
fill="${fill}"
stroke="${stroke}"
stroke-width="${isSelected ? 1.5 : 1}"
class="stream-bubble"
data-pidx="${s.pIdx}"
${s.ci !== undefined ? `data-cidx="${s.ci}"` : ''}
data-si="${s.si}"
style="cursor:pointer; transition:all 0.2s"
>
<title>${s.name} (${isSelected ? 'Selected' : 'Deselected'})</title>
</circle>`
);
cx += r + (radii[idx + 1] ?? r) + SPACING;
});
const svgW = cx - SPACING;
const icon = L.divIcon({
className: 'stream-circles-icon',
html: `<svg width="${svgW}" height="${svgH}" viewBox="0 0 ${svgW} ${svgH}">${circleParts.join('')}</svg>`,
iconSize: [svgW, svgH],
iconAnchor: [svgW / 2, svgH + 10],
});
const markerPane = renderingMode === 'children' ? 'expandedBubblesPane' : 'streamBubblesPane';
return (
<Marker
key={`circles-${gIdx}-${lat}-${lon}`}
position={[lat, lon]}
icon={icon}
interactive={true}
draggable={!locked && renderingMode !== 'children'}
pane={markerPane}
eventHandlers={{
click: (e) => {
const target = e.originalEvent.target as HTMLElement;
if (target.tagName === 'circle') {
const pid = parseInt(target.getAttribute('data-pidx') || '');
const cid = target.getAttribute('data-cidx');
const sid = parseInt(target.getAttribute('data-si') || '');
if (onSelectionToggle) {
onSelectionToggle(pid, sid); // Toggle handles it? Wait, my store might need cIdx
} else if (onElementDoubleClick) {
onElementDoubleClick('stream', pid, cid !== null ? parseInt(cid) : sid);
}
}
},
dragend: (e) => {
if (onDragEnd && renderingMode !== 'children') onDragEnd(e, 'group', String(gIdx));
}
}}
/>
);
})}
</>
);
}
// ─── Connection Lines ─────────────────────────────────────────────────────────
function ConnectionLines({
processes,
groups,
groupNames,
groupCoordinates,
subprocessMapExpanded,
childMapExpanded,
}: {
processes: ProcessNode[],
groups: number[][],
groupNames: string[],
groupCoordinates: Record<string, GroupCoords>,
subprocessMapExpanded: Record<number, boolean>,
childMapExpanded: Record<number, boolean>
}) {
const map = useMap();
const connections = useMemo(() => {
const result: React.ReactElement[] = [];
// Determine which nodes are currently visible
const visibleSubIdxs = new Set<number>();
const visibleChildIdxs = new Map<number, Set<number>>(); // subIdx -> childIdxs
const expandedGroups = new Set<number>();
groups.forEach((subIdxs, gIdx) => {
if (subprocessMapExpanded[gIdx]) {
expandedGroups.add(gIdx);
subIdxs.forEach(si => {
if (childMapExpanded[si]) {
const cSet = new Set<number>();
(processes[si].children || []).forEach((_, ci) => cSet.add(ci));
visibleChildIdxs.set(si, cSet);
} else {
visibleSubIdxs.add(si);
}
});
}
});
// Helper to draw a single connection
const drawConn = (p: ProcessNode, tgt: ProcessNode) => {
const srcLat = parseFloat(String(p.lat));
const srcLon = parseFloat(String(p.lon));
const tLat = parseFloat(String(tgt.lat));
const tLon = parseFloat(String(tgt.lon));
if (isNaN(srcLat) || isNaN(srcLon) || isNaN(tLat) || isNaN(tLon)) return null;
const srcScale = p.box_scale ? parseFloat(String(p.box_scale)) : 1.0;
const tgtScale = tgt.box_scale ? parseFloat(String(tgt.box_scale)) : 1.0;
const p1 = map.latLngToContainerPoint([srcLat, srcLon]);
const p2 = map.latLngToContainerPoint([tLat, tLon]);
const dist = Math.sqrt(Math.pow(p2.x - p1.x, 2) + Math.pow(p2.y - p1.y, 2));
if (dist < 40) return null;
const angleRad = Math.atan2(p2.y - p1.y, p2.x - p1.x);
const angleDeg = angleRad * (180 / Math.PI);
const cos = Math.abs(Math.cos(angleRad));
const sin = Math.abs(Math.sin(angleRad));
const getOffset = (name: string, scale: number) => {
const w = (name.length * 6 + 12) * scale / 2;
const h = (12 * scale + 8) / 2;
return Math.min(w / (cos || 0.001), h / (sin || 0.001));
};
const startOffset = getOffset(p.name, srcScale) + 2;
const endOffset = getOffset(tgt.name, tgtScale) + 2;
if (dist <= startOffset + endOffset + 10) return null;
const startRatio = startOffset / dist;
const arrowRatio = (dist - endOffset) / dist;
const lineEndRatio = (dist - endOffset - 12) / dist;
const pStart = L.point(p1.x + (p2.x - p1.x) * startRatio, p1.y + (p2.y - p1.y) * startRatio);
const pArrow = L.point(p1.x + (p2.x - p1.x) * arrowRatio, p1.y + (p2.y - p1.y) * arrowRatio);
const pLineEnd = L.point(p1.x + (p2.x - p1.x) * lineEndRatio, p1.y + (p2.y - p1.y) * lineEndRatio);
const lStart = map.containerPointToLatLng(pStart);
const lArrow = map.containerPointToLatLng(pArrow);
const lEnd = map.containerPointToLatLng(pLineEnd);
const icon = L.divIcon({
className: 'custom-arrow',
html: `<div style="transform: rotate(${angleDeg}deg); display: flex; align-items: center; justify-content: center; width: 20px; height: 20px;">
<svg width="20" height="20" viewBox="0 0 20 20" overflow="visible" style="display: block;">
<path d="M2,4 L18,10 L2,16 L6,10 Z" fill="var(--text-main)" />
</svg>
</div>`,
iconSize: [20, 20],
iconAnchor: [18, 10],
});
return (
<React.Fragment key={`conn-${p.name}-${tgt.name}`}>
<Polyline positions={[lStart, lEnd]} pathOptions={{ color: 'var(--text-main)', weight: 3, pane: 'activeMarkerPane' }} />
<Marker position={lArrow} icon={icon} interactive={false} pane="activeMarkerPane" />
</React.Fragment>
);
};
// 1. Level 0 connections (between Groups)
if (expandedGroups.size === 0) {
const groupConns = new Set<string>();
groups.forEach((subIdxs, gIdx) => {
subIdxs.forEach(si => {
const p = processes[si];
if (!p.next) return;
const targets = p.next.split(',').map(n => n.trim()).filter(Boolean);
targets.forEach(tName => {
let targetGIdx = -1;
groups.forEach((tSubIdxs, tgIdx) => {
if (tSubIdxs.some(tsi => processes[tsi].name === tName)) {
targetGIdx = tgIdx;
}
});
if (targetGIdx !== -1 && targetGIdx !== gIdx) {
const pairId = `${gIdx}-${targetGIdx}`;
if (!groupConns.has(pairId)) {
groupConns.add(pairId);
const srcGC = groupCoordinates[gIdx];
const tgtGC = groupCoordinates[targetGIdx];
if (srcGC && tgtGC) {
const srcNode = {
name: groupNames[gIdx] || `Process ${gIdx + 1}`,
lat: srcGC.lat, lon: srcGC.lon,
box_scale: srcGC.box_scale || 1.5
} as any;
const tgtNode = {
name: groupNames[targetGIdx] || `Process ${targetGIdx + 1}`,
lat: tgtGC.lat, lon: tgtGC.lon,
box_scale: tgtGC.box_scale || 1.5
} as any;
const el = drawConn(srcNode, tgtNode);
if (el) result.push(el);
}
}
}
});
});
});
}
// 2. Connections within Level 1 (expanded groups)
visibleSubIdxs.forEach(si => {
const p = processes[si];
if (!p.next) return;
const targets = p.next.split(',').map(n => n.trim()).filter(Boolean);
targets.forEach(tName => {
// Find target among visible siblings
const tgt = Array.from(visibleSubIdxs).map(idx => processes[idx]).find(pp => pp.name === tName);
if (tgt) {
const el = drawConn(p, tgt);
if (el) result.push(el);
}
});
});
// 3. Connections within Level 2 (expanded children)
visibleChildIdxs.forEach((cIdxs, si) => {
const sub = processes[si];
cIdxs.forEach(ci => {
const p = sub.children![ci];
if (!p.next) return;
const targets = p.next.split(',').map(n => n.trim()).filter(Boolean);
targets.forEach(tName => {
const tgt = sub.children!.find(pp => pp.name === tName);
if (tgt) {
const el = drawConn(p, tgt);
if (el) result.push(el);
}
});
});
});
return result;
}, [processes, groups, subprocessMapExpanded, childMapExpanded, map]);
return <>{connections}</>;
}
export default function MapViewer({
center,
zoom,
locked,
processes,
groups,
groupNames,
groupCoordinates,
baseTile,
onClick: _onClick,
onMoveEnd,
subprocessMapExpanded,
childMapExpanded,
onProcessesChange,
onGroupCoordinatesChange,
onElementDoubleClick,
height,
selectedStreams,
onProcessesSelect,
onSelectionToggle,
selectionActive,
className,
allowMultiMove = false,
}: Props) {
const [isFullscreen, setIsFullscreen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const lastSelectionTime = useRef(0);
// Clear selection if we click empty map space (and didn't just finish a box selection)
const handleMapClick = (lat: number, lon: number) => {
if (_onClick) _onClick(lat, lon);
// Tiny 100ms guard for the box selector
if (Date.now() - lastSelectionTime.current < 100) return;
if (selectedIds.size > 0) {
setSelectedIds(new Set());
}
};
useEffect(() => {
if (isFullscreen) {
document.body.style.overflow = 'hidden';
} else {
document.body.style.overflow = '';
}
}, [isFullscreen]);
const handleDragEnd = (e: L.LeafletEvent, type: 'group' | 'sub' | 'child', id: string) => {
const marker = e.target as L.Marker;
const { lat, lng } = marker.getLatLng();
// 1. Calculate delta if part of a multi-selection
const markerKey = `${type}-${id}`;
let dLat = 0;
let dLon = 0;
if (allowMultiMove && selectedIds.has(markerKey)) {
// Find original position to calc delta
let oldLat = 0;
let oldLon = 0;
if (type === 'group') {
oldLat = parseFloat(String(groupCoordinates[id]?.lat || 0));
oldLon = parseFloat(String(groupCoordinates[id]?.lon || 0));
} else if (type === 'sub') {
oldLat = parseFloat(String(processes[parseInt(id)]?.lat || 0));
oldLon = parseFloat(String(processes[parseInt(id)]?.lon || 0));
} else if (type === 'child') {
const [si, ci] = id.split('-').map(Number);
oldLat = parseFloat(String(processes[si]?.children?.[ci]?.lat || 0));
oldLon = parseFloat(String(processes[si]?.children?.[ci]?.lon || 0));
}
dLat = lat - oldLat;
dLon = lng - oldLon;
}
// 2. Prepare updates
let nextGroupCoords = { ...groupCoordinates };
let nextProcesses = [...processes];
let changedGroup = false;
let changedProc = false;
// Apply to selected set or just this marker
const targets = (allowMultiMove && selectedIds.has(markerKey)) ? Array.from(selectedIds) : [markerKey];
targets.forEach(key => {
const [tType, tId] = key.split('-') as ['group' | 'sub' | 'child', string];
let tLat = lat;
let tLon = lng;
if (key !== markerKey) {
// Apply delta to others
if (tType === 'group') {
tLat = parseFloat(String(groupCoordinates[tId]?.lat || 0)) + dLat;
tLon = parseFloat(String(groupCoordinates[tId]?.lon || 0)) + dLon;
} else if (tType === 'sub') {
tLat = parseFloat(String(processes[parseInt(tId)]?.lat || 0)) + dLat;
tLon = parseFloat(String(processes[parseInt(tId)]?.lon || 0)) + dLon;
} else if (tType === 'child') {
const [si, ci] = tId.split('-').map(Number);
tLat = parseFloat(String(processes[si]?.children?.[ci]?.lat || 0)) + dLat;
tLon = parseFloat(String(processes[si]?.children?.[ci]?.lon || 0)) + dLon;
}
}
if (tType === 'group') {
nextGroupCoords[tId] = { ...(nextGroupCoords[tId] || {}), lat: tLat.toString(), lon: tLon.toString() };
changedGroup = true;
} else if (tType === 'sub') {
const sIdx = parseInt(tId);
nextProcesses[sIdx] = { ...nextProcesses[sIdx], lat: tLat.toString(), lon: tLon.toString() };
changedProc = true;
} else if (tType === 'child') {
const [si, ci] = tId.split('-').map(Number);
nextProcesses[si] = { ...nextProcesses[si] };
nextProcesses[si].children = [...(nextProcesses[si].children || [])];
nextProcesses[si].children[ci] = { ...nextProcesses[si].children[ci], lat: tLat.toString(), lon: tLon.toString() };
changedProc = true;
}
});
if (changedGroup && onGroupCoordinatesChange) onGroupCoordinatesChange(nextGroupCoords);
if (changedProc && onProcessesChange) onProcessesChange(nextProcesses);
};
const tileUrl = TILE_URLS[baseTile] || TILE_URLS.OpenStreetMap;
const isCanvasActive = useMemo(
() => Object.values(childMapExpanded).some(Boolean) || Object.values(subprocessMapExpanded).some(Boolean),
[childMapExpanded, subprocessMapExpanded]
);
return (
<div
className={`map-viewer-container ${className || ''} ${isFullscreen ? 'fullscreen' : ''} ${selectionActive ? 'pa-selection-active' : ''}`}
style={!isFullscreen ? { height: height || '850px', flex: 'none', position: 'relative' } : { position: 'fixed', inset: 0, zIndex: 9999 }}
>
<div className="map-viewer-controls" style={{ position: 'absolute', top: 10, right: 10, zIndex: 1000, display: 'flex', gap: 8 }}>
<button
className="pa-btn"
onClick={() => setIsFullscreen(!isFullscreen)}
title={isFullscreen ? "Exit Fullscreen" : "Fullscreen"}
style={{ padding: '6px 10px' }}
>
{isFullscreen ? '✕' : '⛶'}
</button>
</div>
<MapContainer
center={center}
zoom={zoom}
style={{ height: '100%', width: '100%' }}
zoomControl={!locked}
dragging={!locked}
scrollWheelZoom={!locked}
doubleClickZoom={!locked}
boxZoom={!locked}
keyboard={!locked}
zoomSnap={0.25}
zoomDelta={0.25}
>
<TileLayer url={tileUrl} />
<MapController center={center} zoom={zoom} locked={locked} />
<MapClickHandler onClick={handleMapClick} />
<MapFullscreenResizer active={isFullscreen} />
<MapMountFitter center={center} zoom={zoom} />
{/* 1. Base Site Bubbles — Hidden by canvas if active */}
<Pane name="streamBubblesPane" style={{ zIndex: 450 }}>
<StreamCirclesOverlay
processes={processes}
groups={groups}
groupNames={groupNames}
groupCoordinates={groupCoordinates}
selectedStreams={selectedStreams}
onSelectionToggle={onSelectionToggle}
onElementDoubleClick={onElementDoubleClick}
locked={locked}
onDragEnd={handleDragEnd}
/>
</Pane>
{/* 2. Focused Workshop Background — Above site markers, below expanded content */}
<Pane name="canvasPane" style={{ zIndex: 650 }}>
<SubprocessCanvasOverlay active={isCanvasActive} />
</Pane>
{/* 3. Main Site Markers — On top of canvas */}
<Pane name="activeMarkerPane" style={{ zIndex: 600 }}>
{groups.map((subIdxs, gIdx) => {
const gCoords = groupCoordinates[gIdx];
const showSubs = subprocessMapExpanded[gIdx];
if (!showSubs) {
const lat = parseFloat(String(gCoords?.lat ?? ''));
const lon = parseFloat(String(gCoords?.lon ?? ''));
if (isNaN(lat) || isNaN(lon)) return null;
const gScale = gCoords?.box_scale ? parseFloat(String(gCoords.box_scale)) : 1.5;
const isAnySelected = selectedStreams === undefined || subIdxs.some(si => {
const p = processes[si];
// If it has no streams, count as "selected" so it's opaque during placement
if ((p.streams || []).length === 0) return true;
return (p.streams || []).some((_, sidx) => selectedStreams?.[`stream_${si}_${sidx}`] !== false);
});
return (
<Marker
key={`group-${gIdx}`}
position={[lat, lon]}
draggable={true}
pane="activeMarkerPane"
eventHandlers={{
click: () => {
if (onProcessesSelect) onProcessesSelect(subIdxs, !isAnySelected);
},
dragend: (e) => handleDragEnd(e, 'group', String(gIdx))
}}
icon={createDivIcon(
'',
groupNames[gIdx] || `Process ${gIdx + 1}`,
'',
gScale,
isAnySelected,
allowMultiMove && selectedIds.has(`group-${gIdx}`),
'group',
String(gIdx)
)}
/>
);
}
return null;
})}
<ConnectionLines
processes={processes}
groups={groups}
groupNames={groupNames}
groupCoordinates={groupCoordinates}
subprocessMapExpanded={subprocessMapExpanded}
childMapExpanded={childMapExpanded}
/>
</Pane>
{/* 4. Hierarchical Expanded Content — Above site markers */}
<Pane name="expandedContentPane" style={{ zIndex: 700 }}>
{/* Subprocesses (if group expanded) */}
{groups.map((subIdxs, gIdx) => {
if (!subprocessMapExpanded[gIdx]) return null;
return subIdxs.map((si) => {
const p = processes[si];
if (!p) return null;
const lat = parseFloat(String(p.lat));
const lon = parseFloat(String(p.lon));
if (isNaN(lat) || isNaN(lon)) return null;
const isSubSelected = selectedStreams === undefined || (p.streams || []).length === 0 || (p.streams || []).some((_, sidx) => selectedStreams?.[`stream_${si}_${sidx}`] !== false);
return (
<Marker
key={`sub-${si}`}
position={[lat, lon]}
draggable={true}
pane="expandedContentPane"
eventHandlers={{
click: () => {
if (onProcessesSelect) onProcessesSelect([si], !isSubSelected);
},
dragend: (e) => handleDragEnd(e, 'sub', String(si))
}}
icon={createDivIcon(
'',
p.name,
'',
p.box_scale ? parseFloat(String(p.box_scale)) : 1.2,
isSubSelected,
allowMultiMove && selectedIds.has(`sub-${si}`),
'sub',
String(si)
)}
/>
);
});
})}
{/* Children (if subprocess expanded) */}
{processes.map((p, si) => {
if (!childMapExpanded[si]) return null;
return (p.children || []).map((child, ci) => {
const lat = parseFloat(String(child.lat));
const lon = parseFloat(String(child.lon));
if (isNaN(lat) || isNaN(lon)) return null;
const isChildSelected = selectedStreams === undefined || (child.streams || []).length === 0 || (child.streams || []).some((_, sidx) => selectedStreams?.[`stream_${si}_${ci}_${sidx}`] !== false);
return (
<Marker
key={`child-${si}-${ci}`}
position={[lat, lon]}
draggable={true}
pane="expandedContentPane"
eventHandlers={{
dragend: (e) => handleDragEnd(e, 'child', `${si}-${ci}`)
}}
icon={createDivIcon(
'',
child.name,
'',
child.box_scale ? parseFloat(String(child.box_scale)) : 0.9,
isChildSelected,
allowMultiMove && selectedIds.has(`child-${si}-${ci}`),
'child',
`${si}-${ci}`
)}
/>
);
});
})}
</Pane>
{/* 5. Hierarchical Expanded Bubbles — Top-most interaction */}
<Pane name="expandedBubblesPane" style={{ zIndex: 750 }}>
<StreamCirclesOverlay
processes={processes}
groups={[]}
groupNames={[]}
groupCoordinates={{}}
selectedStreams={selectedStreams}
onSelectionToggle={onSelectionToggle}
onElementDoubleClick={onElementDoubleClick}
locked={locked}
onDragEnd={handleDragEnd}
renderingMode="children"
childMapExpanded={childMapExpanded}
/>
</Pane>
<BoxSelector
active={locked} // Always active when map is locked
onSelect={(bounds) => {
lastSelectionTime.current = Date.now();
const idxsInBox: number[] = [];
const newSelected = new Set<string>();
// 1. Check groups
groups.forEach((subIdxs, gIdx) => {
const gc = groupCoordinates[gIdx];
if (gc && gc.lat && gc.lon) {
const lat = parseFloat(String(gc.lat));
const lon = parseFloat(String(gc.lon));
if (bounds.contains([lat, lon])) {
idxsInBox.push(...subIdxs);
if (allowMultiMove) newSelected.add(`group-${gIdx}`);
}
}
});
// 2. Check independent processes
processes.forEach((p, pi) => {
if (p.lat && p.lon) {
const lat = parseFloat(String(p.lat));
const lon = parseFloat(String(p.lon));
if (bounds.contains([lat, lon])) {
idxsInBox.push(pi);
if (allowMultiMove) newSelected.add(`sub-${pi}`);
}
}
});
if (allowMultiMove) setSelectedIds(newSelected);
// 3. Smart Toggle for Analysis Streams
if (onProcessesSelect && idxsInBox.length > 0) {
const uniqueIdxs = Array.from(new Set(idxsInBox));
const allInBoxSelected = uniqueIdxs.every(pi => {
const p = processes[pi];
return (p.streams || []).every((_: any, si: number) => selectedStreams?.[`stream_${pi}_${si}`] !== false);
});
onProcessesSelect(uniqueIdxs, !allInBoxSelected);
}
}}
/>
{onMoveEnd && <MapMoveHandler onMoveEnd={onMoveEnd} locked={locked} />}
<MapMountFitter center={center} zoom={zoom} />
<MapFullscreenResizer active={isFullscreen} />
</MapContainer>
</div>
);
}