diff --git a/src/views/Simulation/Simulation.js b/src/views/Simulation/Simulation.js index f257a19aa..49e8bded5 100644 --- a/src/views/Simulation/Simulation.js +++ b/src/views/Simulation/Simulation.js @@ -10,7 +10,7 @@ import { SIMULATION_MODES } from './constants/settings'; const SimulationContent = () => { const inspectorDrawerParentContainerRef = useRef(null); - const { viewMode, setViewMode } = useSimulationViewContext(); + const { viewMode, setViewMode, graphRef } = useSimulationViewContext(); return ( {
- {viewMode === SIMULATION_MODES.GRAPH ? : } + {viewMode === SIMULATION_MODES.GRAPH ? : }
diff --git a/src/views/Simulation/components/PixiComponents/components/pixiNode.js b/src/views/Simulation/components/PixiComponents/components/pixiNode.js new file mode 100644 index 000000000..e84c14b34 --- /dev/null +++ b/src/views/Simulation/components/PixiComponents/components/pixiNode.js @@ -0,0 +1,87 @@ +// Copyright (c) Cosmo Tech. +// Licensed under the MIT license. +import * as PIXI from 'pixi.js'; + +const STYLE = { + coreIdleRadius: 1, + coreFill: 0xb9bac0, + ringColor: 0xffffff, + ringWidth: 2, + fontSize: 18, + fontColor: 0x000000, + minRadius: 2, + maxRadius: 20, +}; + +function drawCore(g, r, fill, ringColor, ringWidth) { + g.clear(); + g.beginFill(fill, 1); + g.drawCircle(0, 0, r); + g.endFill(); + if (ringWidth > 0) { + g.lineStyle(ringWidth, ringColor, 1); + g.drawCircle(0, 0, r); + } +} + +export function ensureStockNode(rootContainer, id, app, _textureCache) { + const name = `node-${id}`; + let node = rootContainer.getChildByName(name); + + if (!node) { + node = new PIXI.Container(); + node.name = name; + rootContainer.addChild(node); + + const halo = new PIXI.Graphics(); + halo.name = 'halo'; + halo.visible = false; + node.addChild(halo); + + const core = new PIXI.Graphics(); + core.name = 'core'; + core.interactive = true; + core.cursor = 'pointer'; + node.addChild(core); + + const label = new PIXI.Text('', { + fontFamily: 'Arial', + fontSize: STYLE.fontSize, + fill: STYLE.fontColor, + align: 'center', + }); + + label.name = 'label'; + label.anchor.set(0.5); + node.addChild(label); + } + + function update({ x, y, worldScale, value }) { + const core = node.getChildByName('core'); + const label = node.getChildByName('label'); + + const v = Number(value ?? 0); + const r = v > 0 ? Math.min(STYLE.maxRadius, STYLE.minRadius + Math.log10(v + 1) * 10) : STYLE.coreIdleRadius; + + drawCore(core, r, STYLE.coreFill, STYLE.ringColor, STYLE.ringWidth); + + label.text = v > 0 ? String(v) : ''; + label.style.fontSize = Math.max(10, r * 0.8); + label.position.set(0, 0); + + node.position.set(x, y); + node.scale.set(worldScale); + } + + return { container: node, update }; +} + +export function removeStockNode(rootContainer, id) { + const name = `node-${id}`; + const n = rootContainer.getChildByName(name); + if (n && n.parent) n.parent.removeChild(n); +} + +export function setStockNodeStyle(partial) { + Object.assign(STYLE, partial || {}); +} diff --git a/src/views/Simulation/components/PixiComponents/components/pixiTransport.js b/src/views/Simulation/components/PixiComponents/components/pixiTransport.js new file mode 100644 index 000000000..f46aa06d7 --- /dev/null +++ b/src/views/Simulation/components/PixiComponents/components/pixiTransport.js @@ -0,0 +1,237 @@ +// Copyright (c) Cosmo Tech. +// Licensed under the MIT license. +import * as PIXI from 'pixi.js'; + +function qPoint(t, P0, P1, P2) { + const u = 1 - t; + return { + x: u * u * P0.x + 2 * u * t * P1.x + t * t * P2.x, + y: u * u * P0.y + 2 * u * t * P1.y + t * t * P2.y, + }; +} +function qTangent(t, P0, P1, P2) { + const u = 1 - t; + return { + x: 2 * (u * (P1.x - P0.x) + t * (P2.x - P1.x)), + y: 2 * (u * (P1.y - P0.y) + t * (P2.y - P1.y)), + }; +} +function normalize(vx, vy) { + const m = Math.hypot(vx, vy) || 1e-6; + return { x: vx / m, y: vy / m }; +} + +function makeTriangle({ tip, dir, perp, aLen, aWid }) { + const base = { x: tip.x - dir.x * aLen, y: tip.y - dir.y * aLen }; + const left = { x: base.x + perp.x * (aWid / 2), y: base.y + perp.y * (aWid / 2) }; + const right = { x: base.x - perp.x * (aWid / 2), y: base.y - perp.y * (aWid / 2) }; + return [ + [tip.x, tip.y], + [left.x, left.y], + [right.x, right.y], + ]; +} + +export function ensureTransportEdge(rootContainer, id) { + const name = `transport-${id}`; + let node = rootContainer.getChildByName(name); + + if (!node) { + node = new PIXI.Container(); + node.name = name; + rootContainer.addChild(node); + + const path = new PIXI.Graphics(); + path.name = 'path'; + node.addChild(path); + + const arrow = new PIXI.Graphics(); + arrow.name = 'arrow'; + node.addChild(arrow); + } + + function update({ + src, + dst, + worldScale = 1, + pixelRatio = 1, // add DPR awareness while keeping your behavior + curvature = 0.18, + widthPx = 4, + color = 0xffffff, + alpha = 1.0, + + // Arrow sizing: scales with stroke thickness by default + arrowLenPx = 14, + arrowWidthPx = 12, + + // Arrow position along curve + arrowAtT = 0.5, + + // Style + dashed = false, + dashPx = 8, + gapPx = 6, + + // Arrow shape + arrowShape = 'triangle', + + // Stability & UX + forceMiddleArrow = true, // keep arrow at midpoint unless explicitly overridden + arrowAutoSize = true, // scale arrow with line thickness + arrowLenPerStroke = 4.5, // multipliers when arrowAutoSize=true + arrowWidthPerStroke = 3.2, + maxArrowAsChordFrac = 0.35, + + // Optional: thin very long (cluster) edges so they don't look too heavy + autoThinByLength = true, + lengthForNoThinPx = 140, + minThicknessScale = 0.5, + attenuationExponent = 0.8, + }) { + if (!src || !dst) return; + + const path = node.getChildByName('path'); + const arrow = node.getChildByName('arrow'); + + // Convert screen px to world units (constant on-screen size) + const denom = Math.max(1e-6, (worldScale || 1) * (pixelRatio || 1)); + const baseW = Math.max(0.5, widthPx / denom); + const dash = Math.max(0.5, dashPx / denom); + const gap = Math.max(0.5, gapPx / denom); + + // Geometry + const dx = dst.x - src.x; + const dy = dst.y - src.y; + const chordLenWorld = Math.hypot(dx, dy) || 1; + const mid = { x: (src.x + dst.x) / 2, y: (src.y + dst.y) / 2 }; + const nrm = normalize(-dy, dx); + const ctrl = { + x: mid.x + nrm.x * curvature * chordLenWorld, + y: mid.y + nrm.y * curvature * chordLenWorld, + }; + + // Auto-thin long edges in screen space + const screenLenPx = chordLenWorld * (worldScale || 1) * (pixelRatio || 1); + let thinScale = 1; + if (autoThinByLength && screenLenPx > lengthForNoThinPx) { + const ratio = screenLenPx / Math.max(1, lengthForNoThinPx); + thinScale = Math.max(minThicknessScale, Math.pow(1 / ratio, attenuationExponent)); + } + const w = baseW * thinScale; + + // Arrow sizing: derived from stroke unless explicitly overridden + const aLenBase = arrowAutoSize ? Math.max(w * arrowLenPerStroke, w * 2.0) : Math.max(2, arrowLenPx / denom); + const aWidBase = arrowAutoSize ? Math.max(w * arrowWidthPerStroke, w * 1.4) : Math.max(2, arrowWidthPx / denom); + + // Clamp arrow against chord length (prevents oversized arrows on short links) + const maxArrowLenWorld = Math.max(0.5, maxArrowAsChordFrac * chordLenWorld); + const aLen = Math.min(aLenBase, maxArrowLenWorld); + const aWid = Math.min(aWidBase, maxArrowLenWorld * 0.7); + + // ---------------- Path (Pixi v8) ---------------- + path.clear(); + path.setStrokeStyle({ + width: w, + color, + alpha, + cap: 'round', + join: 'round', + miterLimit: 2, + }); + + if (!dashed) { + path.moveTo(src.x, src.y); + path.quadraticCurveTo(ctrl.x, ctrl.y, dst.x, dst.y); + } else { + // Dash sampling ~2px in screen space for crispness + const stepPx = 2; + const stepWorld = Math.max(0.5 / denom, stepPx / denom); + const segments = Math.max(16, Math.min(640, Math.ceil(chordLenWorld / stepWorld))); + const total = dash + gap; + + let acc = 0; + let prev = qPoint(0, src, ctrl, dst); + + for (let i = 1; i <= segments; i++) { + const t = i / segments; + const p = qPoint(t, src, ctrl, dst); + const segLen = Math.hypot(p.x - prev.x, p.y - prev.y); + if (segLen === 0) { + prev = p; + continue; + } + + let walked = 0; + while (walked < segLen) { + const inDash = acc % total < dash; + const remaining = (inDash ? dash : total) - (acc % total); + const step = Math.min(segLen - walked, remaining); + + const r1 = walked / segLen; + const r2 = (walked + step) / segLen; + const x1 = prev.x + (p.x - prev.x) * r1; + const y1 = prev.y + (p.y - prev.y) * r1; + const x2 = prev.x + (p.x - prev.x) * r2; + const y2 = prev.y + (p.y - prev.y) * r2; + + if (inDash) { + path.moveTo(x1, y1); + path.lineTo(x2, y2); + } + walked += step; + acc += step; + } + prev = p; + } + } + + path.stroke(); // <-- v8: explicitly render the stroke + + // ---------------- Arrowhead (midpoint, stable orientation) ---------------- + const tt = forceMiddleArrow ? 0.5 : Math.max(0.02, Math.min(0.98, arrowAtT)); + const tip = qPoint(tt, src, ctrl, dst); + + // chord-aligned fallback to prevent zoom-direction flips + const chord = { x: dx, y: dy }; + const chordLen = Math.hypot(chord.x, chord.y) || 1e-6; + const baseDir = { x: chord.x / chordLen, y: chord.y / chordLen }; + + const tan = qTangent(tt, src, ctrl, dst); + const tanLen = Math.hypot(tan.x, tan.y); + + let dir; + if (tanLen < 1e-6) { + dir = baseDir; + } else { + const cand = { x: tan.x / tanLen, y: tan.y / tanLen }; + const dot = cand.x * baseDir.x + cand.y * baseDir.y; + dir = dot >= 0 ? cand : { x: -cand.x, y: -cand.y }; + } + const perp = { x: -dir.y, y: dir.x }; + + let points; + if (typeof arrowShape === 'function') { + points = arrowShape({ tip, dir, perp, aLen, aWid }); + } else { + points = makeTriangle({ tip, dir, perp, aLen, aWid }); + } + + arrow.clear(); + arrow.setFillStyle(color, alpha); + arrow.moveTo(points[0][0], points[0][1]); + for (let i = 1; i < points.length; i++) arrow.lineTo(points[i][0], points[i][1]); + arrow.closePath(); + arrow.fill(); // <-- v8 fill + } + + return { container: node, update }; +} + +/** Remove a transport edge container by id */ +export function removeTransportEdge(rootContainer, id) { + const name = `transport-${id}`; + const n = rootContainer.getChildByName(name); + if (!n) return; + rootContainer.removeChild(n); + n.destroy({ children: true }); +} diff --git a/src/views/Simulation/components/Scene/MapView.js b/src/views/Simulation/components/Scene/MapView.js index bbf812805..29d8dee21 100644 --- a/src/views/Simulation/components/Scene/MapView.js +++ b/src/views/Simulation/components/Scene/MapView.js @@ -1,13 +1,17 @@ // Copyright (c) Cosmo Tech. // Licensed under the MIT license. -import React, { useEffect, useRef, useState } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import PropTypes from 'prop-types'; import { useTheme } from '@mui/styles'; +import * as d3 from 'd3'; +import * as PIXI from 'pixi.js'; import { createApp, initMapApp, destroyApp, generateMap } from '../../utils/pixiUtils'; +import { ensureStockNode, removeStockNode } from '../PixiComponents/components/pixiNode'; +import { ensureTransportEdge, removeTransportEdge } from '../PixiComponents/components/pixiTransport'; import { IncidentChip } from './components/IncidentChip'; -const MapView = () => { +const MapView = ({ graph }) => { const theme = useTheme(); - const mapContainerRef = useRef(null); const mapCanvasRef = useRef(null); const mapAppRef = useRef(null); // eslint-disable-next-line no-unused-vars @@ -16,20 +20,147 @@ const MapView = () => { position: { x: 0, y: 0 }, data: { bottlenecks: 0, shortages: 0 }, }); + const mapContainerRef = useRef(null); + const layersRef = useRef({ links: null, nodes: null }); useEffect(() => { - mapAppRef.current = createApp(); - const setup = async () => { + const app = (mapAppRef.current = createApp()); + + (async () => { await initMapApp(mapAppRef, mapCanvasRef, mapContainerRef, theme); generateMap(mapContainerRef, mapCanvasRef); - }; - setup(); + + if (!app?.stage) return; + app.stage.sortableChildren = true; + + const linksLayer = new PIXI.Container(); + linksLayer.name = 'linksLayer'; + linksLayer.zIndex = 10; + + const nodesLayer = new PIXI.Container(); + nodesLayer.name = 'nodesLayer'; + nodesLayer.zIndex = 11; + + const mapContainer = mapContainerRef.current; + if (mapContainer && mapContainer.addChild) { + mapContainer.addChild(linksLayer); + mapContainer.addChild(nodesLayer); + } else { + app.stage.addChild(linksLayer); + app.stage.addChild(nodesLayer); + } + layersRef.current = { links: linksLayer, nodes: nodesLayer }; + })(); return () => { - destroyApp(mapAppRef.current); + if (mapAppRef.current) destroyApp(mapAppRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); + }, [theme]); + + const projection = useMemo(() => { + const r = mapAppRef.current?.renderer; + if (!r) return null; + return d3 + .geoMercator() + .scale(120) + .translate([r.width / 2, r.height / 1.75]); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [mapAppRef.current?.renderer?.width, mapAppRef.current?.renderer?.height]); + + const prepared = useMemo(() => { + if (!graph || !projection) return null; + + const nodes = (graph.nodes ?? []).map((n) => { + const lat = Number(n?.data?.latitude ?? n?.data?.lat); + const lon = Number(n?.data?.longitude ?? n?.data?.lon ?? n?.data?.lng); + if (Number.isFinite(lat) && Number.isFinite(lon)) { + const [x, y] = projection([lon, lat]); + return { ...n, x, y }; + } + return { ...n, x: undefined, y: undefined }; + }); + + const nodeById = new Map(nodes.map((n) => [String(n.id), n])); + + const links = (graph.links ?? []).map((l) => { + const srcId = typeof l.source === 'object' ? (l.source?.id ?? l.source) : l.source; + const tgtId = typeof l.target === 'object' ? (l.target?.id ?? l.target) : l.target; + const __s = nodeById.get(String(srcId)); + const __t = nodeById.get(String(tgtId)); + return { ...l, __s, __t }; + }); + + // Debug + const nodesWithXY = nodes.filter((n) => Number.isFinite(n.x) && Number.isFinite(n.y)); + const linksReady = links.filter( + (l) => + l.__s && + l.__t && + Number.isFinite(l.__s.x) && + Number.isFinite(l.__s.y) && + Number.isFinite(l.__t.x) && + Number.isFinite(l.__t.y) + ); + + return { nodes, nodesWithXY, links, linksReady }; + }, [graph, projection]); + + useEffect(() => { + if (!prepared) return; + + const app = mapAppRef.current; + const { linksLayer, nodesLayer } = { + linksLayer: layersRef.current?.links, + nodesLayer: layersRef.current?.nodes, + }; + if (!app || !linksLayer || !nodesLayer) return; + + const mapContainer = mapContainerRef.current; + const worldScale = (mapContainer && mapContainer.scale && mapContainer.scale.x) || app.stage?.scale?.x || 1; + const pixelRatio = app.renderer?.resolution ?? window.devicePixelRatio ?? 1; + + const liveNodeIds = new Set(); + for (const n of prepared.nodesWithXY) { + const { update } = ensureStockNode(nodesLayer, n.id, app, null); + update({ + x: n.x, + y: n.y, + worldScale, + }); + liveNodeIds.add(String(n.id)); + } + for (const child of [...nodesLayer.children]) { + if (!child.name?.startsWith('node-')) continue; + const id = child.name.slice('node-'.length); + if (!liveNodeIds.has(id)) removeStockNode(nodesLayer, id); + } + + const liveEdgeIds = new Set(); + for (const l of prepared.linksReady) { + const edgeId = String(l.id ?? `${l.__s.id}→${l.__t.id}`); + const { update } = ensureTransportEdge(linksLayer, edgeId); + update({ + src: { x: l.__s.x, y: l.__s.y }, + dst: { x: l.__t.x, y: l.__t.y }, + worldScale, + pixelRatio, + curvature: 0.18, + widthPx: 0.01 * pixelRatio, + color: 0xffffff, + alpha: 1.0, + dashed: false, + arrowShape: 'triangle', + arrowAtT: 1, + }); + liveEdgeIds.add(edgeId); + } + for (const child of [...linksLayer.children]) { + if (!child.name?.startsWith('transport-')) continue; + const id = child.name.slice('transport-'.length); + if (!liveEdgeIds.has(id)) removeTransportEdge(linksLayer, id); + } + }, [prepared]); return ( <> @@ -43,4 +174,23 @@ const MapView = () => { ); }; +MapView.propTypes = { + graph: PropTypes.shape({ + nodes: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.any.isRequired, + data: PropTypes.object, + type: PropTypes.string, + }) + ), + links: PropTypes.arrayOf( + PropTypes.shape({ + id: PropTypes.any, + source: PropTypes.any.isRequired, + target: PropTypes.any.isRequired, + }) + ), + }), +}; + export default MapView; diff --git a/src/views/Simulation/components/Scene/Scene.js b/src/views/Simulation/components/Scene/Scene.js index e1478c349..4b257c252 100644 --- a/src/views/Simulation/components/Scene/Scene.js +++ b/src/views/Simulation/components/Scene/Scene.js @@ -41,6 +41,14 @@ const Scene = () => { const sceneContainerRef = useRef(null); const sampleMarkers = timelineMarkers; + const getCanvasSize = () => { + const el = graphCanvasRef.current; + if (!el) return null; + const { clientWidth, clientHeight } = el; + if (clientWidth == null || clientHeight == null) return null; + return { clientWidth, clientHeight }; + }; + useEffect(() => { sceneAppRef.current = createApp(); minimapAppRef.current = createApp(); @@ -59,26 +67,58 @@ const Scene = () => { await initMinimap(minimapAppRef, minimapContainerRef, minimapCanvasRef, sceneContainerRef, graphCanvasRef, theme); }; + setup(); + const sceneContainer = sceneContainerRef.current; + const minimapContainer = minimapContainerRef.current; + const graphCanvas = graphCanvasRef.current; + const minimapCanvas = minimapCanvasRef.current; + + let ro; + if (typeof ResizeObserver !== 'undefined' && graphCanvas) { + ro = new ResizeObserver((entries) => { + for (const entry of entries) { + const cr = entry.contentRect; + if (cr && cr.width && cr.height) { + resetGraphLayout(Math.floor(cr.width), Math.floor(cr.height)); + requiredUpdateStepsRef.current.layout = true; + setNeedsReRendering(true); + } + } + }); + ro.observe(graphCanvas); + } + return () => { - destroyApp(sceneAppRef.current); - destroyApp(minimapAppRef.current); - // eslint-disable-next-line react-hooks/exhaustive-deps - sceneContainerRef.current.destroy(graphCanvasRef); - // eslint-disable-next-line react-hooks/exhaustive-deps - minimapContainerRef.current.destroy(minimapCanvasRef); + try { + destroyApp(sceneAppRef.current); + } catch {} + try { + destroyApp(minimapAppRef.current); + } catch {} + try { + sceneContainer?.destroy(graphCanvas); + } catch {} + try { + minimapContainer?.destroy(minimapCanvas); + } catch {} + if (ro && graphCanvas) { + try { + ro.unobserve(graphCanvas); + } catch {} + } }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const centerToPosition = useCallback( () => (elementId) => { - if (sceneContainerRef.current == null) return; + if (!sceneContainerRef.current) return; const newSelectedElement = sceneContainerRef.current.findElementById(elementId); - sceneContainerRef.current.centerOnElement(newSelectedElement); + if (newSelectedElement) sceneContainerRef.current.centerOnElement(newSelectedElement); }, - [sceneContainerRef] + [] ); useEffect(() => { @@ -86,7 +126,7 @@ const Scene = () => { }, [centerToPosition, setCenterToPosition]); useEffect(() => { - if (sceneContainerRef.current == null) return; + if (!sceneContainerRef.current) return; requiredUpdateStepsRef.current.highlight = true; }, [selectedElementId, requiredUpdateStepsRef]); @@ -95,16 +135,21 @@ const Scene = () => { resetGraphHighlighting(graphRef.current, settings, selectedElementId, currentTimestep); updateContainerSprites(sceneContainerRef.current, graphRef.current); - if (minimapContainerRef.current) minimapContainerRef.current.updateMiniScene(); - }, [currentTimestep, settings, graphRef, selectedElementId, sceneContainerRef]); + minimapContainerRef.current?.updateMiniScene(); + }, [currentTimestep, settings, graphRef, selectedElementId]); useEffect(() => { if (!needsReRendering) return; const layoutUpdate = requiredUpdateStepsRef.current.all || requiredUpdateStepsRef.current.layout; + if (layoutUpdate) { - resetGraphLayout(graphCanvasRef.current.clientWidth, graphCanvasRef.current.clientHeight); + const size = getCanvasSize(); + if (size) { + resetGraphLayout(size.clientWidth, size.clientHeight); + } } + if (requiredUpdateStepsRef.current.all) setSelectedElementId(null); if ( @@ -122,7 +167,7 @@ const Scene = () => { const resetBounds = requiredUpdateStepsRef.current.all || requiredUpdateStepsRef.current.layout; renderElements(sceneContainerRef, graphRef, setSelectedElementId, settings, resetBounds); if (layoutUpdate && sceneContainerRef.current) sceneContainerRef.current.setOrigin(); - if (minimapContainerRef.current) minimapContainerRef.current.renderElements(); + minimapContainerRef.current?.renderElements(); } requiredUpdateStepsRef.current = { ...DEFAULT_UPDATE_STATE }; @@ -132,7 +177,6 @@ const Scene = () => { needsReRendering, selectedElementId, setNeedsReRendering, - sceneContainerRef, sceneContainerRef?.current?.textures, graphCanvasRef?.current?.clientWidth, graphCanvasRef?.current?.clientHeight, @@ -149,7 +193,7 @@ const Scene = () => { data-cy="graph-view" ref={graphCanvasRef} style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column' }} - > + /> { + const gx = Math.floor(x / cellSizePx); + const gy = Math.floor(y / cellSizePx); + return `${gx}:${gy}`; + }; + + nodes.forEach((n) => { + if (n.lon == null || n.lat == null) return; + const { x, y } = lonLatToXY(projection, n.lon, n.lat); + const sx = x * scale; + const sy = y * scale; + const k = getKey(sx, sy); + let cell = buckets.get(k); + if (!cell) { + cell = { ids: [], xSum: 0, ySum: 0, count: 0 }; + buckets.set(k, cell); + } + cell.ids.push(n.id); + cell.xSum += sx; + cell.ySum += sy; + cell.count += 1; + }); + + const clusters = []; + for (const [key, cell] of buckets.entries()) { + const cx = cell.xSum / cell.count; + const cy = cell.ySum / cell.count; + clusters.push({ key, ids: cell.ids, count: cell.count, sx: cx, sy: cy }); + } + return clusters; +} + +function ensureClusterSprite(root, cluster) { + const name = `cluster-${cluster.key}`; + let c = root.getChildByName(name); + if (!c) { + c = new PIXI.Container(); + c.name = name; + const circle = new PIXI.Graphics(); + circle.name = 'circle'; + const label = new PIXI.Text(String(cluster.count), CLUSTER_LABEL_STYLE); + label.anchor.set(0.5); + label.name = 'label'; + c.addChild(circle, label); + root.addChild(c); + } + const radius = Math.max(10, Math.min(28, 10 + Math.log2(cluster.count + 1) * 6)); + const circle = c.getChildByName('circle'); + circle.clear(); + circle.beginFill(0xffffff, 0.85); + circle.lineStyle(1, 0x333333, 0.8); + circle.drawCircle(0, 0, radius); + circle.endFill(); + c.position.set(cluster.sx, cluster.sy); + c.scale.set(1.0); + const label = c.getChildByName('label'); + label.text = String(cluster.count); + return c; +} + +export async function createPixiMap({ + canvasRef, + data, + worldGeoJSON = null, + onNodeClick = () => {}, + onClusterClick = () => {}, +}) { + const canvasEl = canvasRef.current; + const app = await makeApp(canvasEl); + + const scene = new SceneContainer(app, canvasRef, { + minZoom: MIN_ZOOM, + maxZoom: MAX_ZOOM, + defaultZoom: DEFAULT_ZOOM, + zoomSpeed: ZOOM_SPEED, + }); + app.stage.addChild(scene); + + const worldLayer = new PIXI.Container(); + const linksLayer = new PIXI.Container(); + const nodesLayer = new PIXI.Container(); + const clustersLayer = new PIXI.Container(); + + scene.addChild(worldLayer, linksLayer, nodesLayer, clustersLayer); + + const view = { width: canvasEl.clientWidth, height: canvasEl.clientHeight }; + const projection = d3.geoMercator().scale(1).translate([0, 0]); + const path = d3.geoPath(projection); + if (worldGeoJSON) { + const b = path.bounds(worldGeoJSON); + const s = 0.95 / Math.max((b[1][0] - b[0][0]) / view.width, (b[1][1] - b[0][1]) / view.height); + const t = [ + (view.width - s * (b[1][0] - b[0][0])) / 2 - s * b[0][0], + (view.height - s * (b[1][1] - b[0][1])) / 2 - s * b[0][1], + ]; + projection.scale(s).translate(t); + } + + const nodeSprites = new Map(); + const edgeSprites = new Map(); + const clusterSprites = new Map(); + + function nodeScreenPos(n, scale = scene.scale.x) { + const { x, y } = lonLatToXY(projection, n.lon, n.lat); + return { x: x * scale, y: y * scale }; + } + + function renderNodes(nodes) { + nodes.forEach((n) => { + const { x, y } = nodeScreenPos(n); + if (!nodeSprites.has(n.id)) { + const { container, update } = ensureStockNode(nodesLayer, n.id, { x, y }, { type: n.type || 'stock' }); + nodeSprites.set(n.id, { container, update }); + container.interactive = true; + container.cursor = 'pointer'; + container.on('pointertap', () => onNodeClick(n)); + } + nodeSprites.get(n.id).update({ x, y }); + }); + [...nodeSprites.keys()].forEach((id) => { + if (!nodes.find((n) => n.id === id)) { + removeStockNode(nodesLayer, id); + nodeSprites.delete(id); + } + }); + } + + function renderEdges(links, nodesById) { + links.forEach((e) => { + const id = e.id || `${e.source}-${e.target}`; + const a = nodesById.get(e.source); + const b = nodesById.get(e.target); + if (!a || !b) return; + const P0 = nodeScreenPos(a); + const P2 = nodeScreenPos(b); + const spacingFactor = 0.5; + const isLayoutHorizontal = Math.abs(P0.x - P2.x) >= Math.abs(P0.y - P2.y); + if (!edgeSprites.has(id)) { + const { container, update } = ensureTransportEdge(linksLayer, id, P0, P2, { + spacingFactor, + isLayoutHorizontal, + }); + edgeSprites.set(id, { container, update }); + } + edgeSprites.get(id).update(P0, P2, { spacingFactor, isLayoutHorizontal }); + }); + [...edgeSprites.keys()].forEach((id) => { + if (!links.find((e) => (e.id || `${e.source}-${e.target}`) === id)) { + removeTransportEdge(linksLayer, id); + edgeSprites.delete(id); + } + }); + } + + function renderClusters(clusters) { + clusters.forEach((c) => { + const sprite = ensureClusterSprite(clustersLayer, c, scene.scale.x); + clusterSprites.set(c.key, sprite); + sprite.interactive = true; + sprite.cursor = 'pointer'; + sprite.removeAllListeners('pointertap'); + sprite.on('pointertap', () => onClusterClick(c)); + }); + [...clusterSprites.keys()].forEach((key) => { + if (!clusters.find((c) => c.key === key)) { + const existingSprite = clustersLayer.getChildByName(`cluster-${key}`); + if (existingSprite) { + clustersLayer.removeChild(existingSprite); + existingSprite.destroy({ children: true }); + } + clusterSprites.delete(key); + } + }); + } + + function recomputeAndRender() { + const scale = scene.scale.x; + const nodes = data.nodes || []; + const links = data.links || []; + const nodesById = new Map(nodes.map((n) => [n.id, n])); + + renderNodes(nodes); + renderEdges(links, nodesById); + + const clusters = buildClusters(nodes, projection, scale); + renderClusters(clusters); + } + + const onTick = () => { + recomputeAndRender(); + }; + app.ticker.add(onTick); + + recomputeAndRender(); + + return () => { + app.ticker.remove(onTick); + app.destroy(true, { children: true }); + while (canvasEl.firstChild) canvasEl.removeChild(canvasEl.firstChild); + }; +} diff --git a/src/views/Simulation/utils/pixiUtils.js b/src/views/Simulation/utils/pixiUtils.js index 3971b3abf..12e85fe9c 100644 --- a/src/views/Simulation/utils/pixiUtils.js +++ b/src/views/Simulation/utils/pixiUtils.js @@ -3,6 +3,7 @@ import * as d3 from 'd3'; import { AdvancedBloomFilter, GlowFilter } from 'pixi-filters'; import { AlphaFilter, Application, BitmapText, Container, Graphics, GraphicsContext, Sprite } from 'pixi.js'; +import * as PIXI from 'pixi.js'; import 'pixi.js/unsafe-eval'; import { NODE_TYPES } from '../constants/nodeLabels'; import worldGeoJSON from '../data/map/custom.geo.json'; @@ -608,27 +609,31 @@ const drawPolygon = (graphics, polygon, projection) => { }; export const generateMap = (mapContainerRef, mapCanvasRef) => { + // clear previous basemap mapContainerRef.current.removeChildren().forEach((child) => { child.destroy({ children: true, texture: false, baseTexture: false }); }); - const projection = d3 - .geoMercator() - .scale(120) - .translate([mapCanvasRef.current.clientWidth / 2, mapCanvasRef.current.clientHeight / 1.65]); + // Build the Mercator used by the basemap + const k = 120; + const tx = mapCanvasRef.current.clientWidth / 2; + const ty = mapCanvasRef.current.clientHeight / 1.75; + const projection = d3.geoMercator().scale(k).translate([tx, ty]); + // Store current mercator on the container and emit a transform event + mapContainerRef.current.__mercator = { k, tx, ty }; + mapContainerRef.current.emit?.('map:transform', { k, tx, ty }); + + // Draw countries for (const feature of worldGeoJSON.features) { const geomType = feature.geometry.type; const coords = feature.geometry.coordinates; - const country = new Graphics(); if (geomType === 'Polygon') { drawPolygon(country, coords, projection); } else if (geomType === 'MultiPolygon') { - for (const polygon of coords) { - drawPolygon(country, polygon, projection); - } + for (const polygon of coords) drawPolygon(country, polygon, projection); } mapContainerRef.current.addChild(country); @@ -671,3 +676,292 @@ export const initMapApp = async (mapAppRef, mapCanvasRef, mapContainerRef, theme window.addEventListener('resize', handleResize); }; + +export const parseSVGPath = (svgPath) => { + if (!svgPath) return []; + + const commands = []; + const regex = /([MLHVCSQTAZmlhvcsqtaz])([^MLHVCSQTAZmlhvcsqtaz]*)/g; + let match; + + while ((match = regex.exec(svgPath)) !== null) { + const command = match[1]; + const params = match[2] + .trim() + .split(/[\s,]+/) + .map(parseFloat); + commands.push({ command, params }); + } + + return commands; +}; + +export const drawSVGPathToPixi = (graphics, pathData) => { + let currentX = 0; + let currentY = 0; + + pathData.forEach(({ command, params }) => { + switch (command) { + case 'M': // Move to absolute + currentX = params[0]; + currentY = params[1]; + graphics.moveTo(currentX, currentY); + break; + case 'm': // Move to relative + currentX += params[0]; + currentY += params[1]; + graphics.moveTo(currentX, currentY); + break; + case 'L': // Line to absolute + currentX = params[0]; + currentY = params[1]; + graphics.lineTo(currentX, currentY); + break; + case 'l': // Line to relative + currentX += params[0]; + currentY += params[1]; + graphics.lineTo(currentX, currentY); + break; + case 'H': // Horizontal line absolute + currentX = params[0]; + graphics.lineTo(currentX, currentY); + break; + case 'h': // Horizontal line relative + currentX += params[0]; + graphics.lineTo(currentX, currentY); + break; + case 'V': // Vertical line absolute + currentY = params[0]; + graphics.lineTo(currentX, currentY); + break; + case 'v': // Vertical line relative + currentY += params[0]; + graphics.lineTo(currentX, currentY); + break; + case 'Z': + case 'z': // Close path + graphics.closePath(); + break; + // Note: Curves (C, c, S, s, Q, q, T, t, A, a) would require more complex handling + default: + // For simplicity, we're ignoring curves in this implementation + break; + } + }); +}; + +export const createStockTexture = (textureCache, color, radius, app) => { + const textureKey = `stock-${color}-${radius}`; + if (textureCache.current[textureKey]) return textureCache.current[textureKey]; + + const graphics = new PIXI.Graphics(); + graphics.beginFill(color, 0.7); + graphics.drawCircle(0, 0, radius); + graphics.endFill(); + graphics.lineStyle(1, 0x000000, 1); + graphics.drawCircle(0, 0, radius); + + const texture = app.renderer.generateTexture(graphics); + textureCache.current[textureKey] = texture; + return texture; +}; + +export const createTransportTexture = (textureCache, sourcePos, targetPos, lineColor, lineWidth, app) => { + const textureKey = `transport-${sourcePos.x}-${sourcePos.y}-${targetPos.x}-${targetPos.y}-${lineColor}-${lineWidth}`; + if (textureCache.current[textureKey]) return textureCache.current[textureKey]; + + const midX = (sourcePos.x + targetPos.x) / 2; + const midY = (sourcePos.y + targetPos.y) / 2; + const dx = targetPos.x - sourcePos.x; + const dy = targetPos.y - sourcePos.y; + const distance = Math.sqrt(dx * dx + dy * dy); + const offset = distance * 0.2; + const controlX = midX - (dy * offset) / distance; + const controlY = midY + (dx * offset) / distance; + + const glowSize = lineWidth * 4; + const padding = Math.max(glowSize, 20); + const minX = Math.min(sourcePos.x, targetPos.x, controlX) - padding; + const minY = Math.min(sourcePos.y, targetPos.y, controlY) - padding; + const maxX = Math.max(sourcePos.x, targetPos.x, controlX) + padding; + const maxY = Math.max(sourcePos.y, targetPos.y, controlY) + padding; + const width = maxX - minX; + const height = maxY - minY; + + const graphics = new PIXI.Graphics(); + + graphics.lineStyle(lineWidth * 1.2, lineColor, 0.3); + graphics.moveTo(sourcePos.x - minX, sourcePos.y - minY); + graphics.quadraticCurveTo(controlX - minX, controlY - minY, targetPos.x - minX, targetPos.y - minY); + + graphics.lineStyle(lineWidth, lineColor, 0.8); + graphics.moveTo(sourcePos.x - minX, sourcePos.y - minY); + graphics.quadraticCurveTo(controlX - minX, controlY - minY, targetPos.x - minX, targetPos.y - minY); + + const texture = app.renderer.generateTexture(graphics, { + resolution: 1, + region: new PIXI.Rectangle(0, 0, width, height), + }); + + textureCache.current[textureKey] = { + texture, + x: minX, + y: minY, + width, + height, + curveData: { + sourceX: sourcePos.x - minX, + sourceY: sourcePos.y - minY, + controlX: controlX - minX, + controlY: controlY - minY, + targetX: targetPos.x - minX, + targetY: targetPos.y - minY, + }, + }; + + return textureCache.current[textureKey]; +}; + +const getQuadraticPoint = (p0x, p0y, p1x, p1y, p2x, p2y, t) => { + const mt = 1 - t; + return { + x: mt * mt * p0x + 2 * mt * t * p1x + t * t * p2x, + y: mt * mt * p0y + 2 * mt * t * p1y + t * t * p2y, + }; +}; + +export const createMovingDots = (curveData, color, app) => { + const container = new PIXI.Container(); + const numDots = 5; + const dots = []; + + for (let i = 0; i < numDots; i++) { + const dot = new PIXI.Graphics(); + const dotSize = 0.5 + Math.random() * 2; + + dot.beginFill(color, 0.8); + dot.drawCircle(0, 0, dotSize); + dot.endFill(); + + dot.beginFill(color, 0.3); + dot.drawCircle(0, 0, dotSize * 2); + dot.endFill(); + + const initialT = i / numDots; + const pos = getQuadraticPoint( + curveData.sourceX, + curveData.sourceY, + curveData.controlX, + curveData.controlY, + curveData.targetX, + curveData.targetY, + initialT + ); + + dot.x = pos.x; + dot.y = pos.y; + + dot.tValue = initialT; + dot.speed = 0.003 + Math.random() * 0.002; + + container.addChild(dot); + dots.push(dot); + } + + container.animateDots = () => { + dots.forEach((dot) => { + dot.tValue += dot.speed; + if (dot.tValue > 1) dot.tValue = 0; + + const pos = getQuadraticPoint( + curveData.sourceX, + curveData.sourceY, + curveData.controlX, + curveData.controlY, + curveData.targetX, + curveData.targetY, + dot.tValue + ); + + dot.x = pos.x; + dot.y = pos.y; + }); + }; + + return container; +}; +export const setGraph = ( + graph, + { mapContainerRef, mapCanvasRef, setSelectedElementId = () => {}, settings = {}, resetBounds = true } = {} +) => { + if (!mapContainerRef?.current) return; + + // Cache original graph for future re-projection (on zoom/pan) + mapContainerRef.current.__originalGraph = graph || { nodes: [], links: [] }; + + // Build projection to mirror basemap + const m = mapContainerRef.current.__mercator; + let projection; + if (m) { + projection = d3.geoMercator().scale(m.k).translate([m.tx, m.ty]); + } else if (mapCanvasRef) { + const k = 120; + const tx = mapCanvasRef.current.clientWidth / 2; + const ty = mapCanvasRef.current.clientHeight / 1.75; + projection = d3.geoMercator().scale(k).translate([tx, ty]); + mapContainerRef.current.__mercator = { k, tx, ty }; + } + + const src = mapContainerRef.current.__originalGraph; + + // 1) Project nodes + const projectedNodes = (src.nodes || []).map((n) => { + const lat = Number(n?.data?.latitude ?? n?.data?.lat); + const lon = Number(n?.data?.longitude ?? n?.data?.lon ?? n?.data?.lng); + if (Number.isFinite(lat) && Number.isFinite(lon) && projection) { + const [x, y] = projection([lon, lat]); + return { ...n, x, y }; + } + return n; // keep existing x/y if no lat/lon + }); + + // 2) Rebuild links so source/target are the *projected node objects* + const nodeById = new Map(projectedNodes.map((n) => [String(n.id), n])); + const projectedLinks = (src.links || []).map((l) => { + const srcId = typeof l.source === 'object' ? (l.source?.id ?? l.source) : l.source; + const tgtId = typeof l.target === 'object' ? (l.target?.id ?? l.target) : l.target; + const source = nodeById.get(String(srcId)); + const target = nodeById.get(String(tgtId)); + return { ...l, source, target }; + }); + + // Clear and redraw basemap (keeps mercator in sync on resize), then overlays + try { + mapContainerRef.current.removeChildren().forEach((child) => { + child.destroy({ children: true, texture: false, baseTexture: false }); + }); + } catch (_) {} + + if (mapCanvasRef) generateMap(mapContainerRef, mapCanvasRef); + + const graphRef = { current: { nodes: projectedNodes, links: projectedLinks } }; + renderElements(mapContainerRef, graphRef, setSelectedElementId, settings, resetBounds); + + if (typeof mapContainerRef.current.setOrigin === 'function') { + mapContainerRef.current.setOrigin(); + } + + // Hook once: re-project on basemap transform + if (!mapContainerRef.current.__transformHooked) { + mapContainerRef.current.on?.('map:transform', () => { + setGraph(mapContainerRef.current.__originalGraph, { + mapContainerRef, + mapCanvasRef, + setSelectedElementId, + settings, + resetBounds: false, // avoid jumping view + }); + }); + mapContainerRef.current.__transformHooked = true; + } +};