From 7469f8fecaf0f3cb129d35ee4a6a433b608282ee Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:50:21 +0300 Subject: [PATCH 001/114] ui: expand stable graph onto a larger virtual canvas --- pipeline/dotagraph_pipeline/layout.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/pipeline/dotagraph_pipeline/layout.py b/pipeline/dotagraph_pipeline/layout.py index 00b4be91..43fa3f3e 100644 --- a/pipeline/dotagraph_pipeline/layout.py +++ b/pipeline/dotagraph_pipeline/layout.py @@ -289,17 +289,17 @@ def compute_layout( def spread_layout_positions( positions: dict[str, tuple[float, float]], *, - width: float = 1200.0, - height: float = 760.0, - margin: float = 24.0, - max_scale: float = 1.18, + width: float = 1900.0, + height: float = 1180.0, + margin: float = 60.0, + max_scale: float = 1.50, ) -> dict[str, tuple[float, float]]: - """Expand an already-stable layout uniformly into more of the graph canvas. + """Expand one stable organic layout into a larger virtual graph canvas. - This pass preserves topology and all relative angles. It only changes the - presentation scale around the current bounds center, so Overview, Hover, - Focus, and Matchup can keep one stable coordinate system while gaining more - breathing room between portraits. + The browser deliberately does not fit this larger canvas perfectly at all + times. That keeps portraits small relative to the space between them, like + a natural force-directed graph, while preserving the exact same coordinates + in Overview, Hover, Focus, and Matchup. """ if not positions: From ef7f98d97757688ecf8cd081b574af0c6763dc09 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:53:32 +0300 Subject: [PATCH 002/114] ui: keep camera stable when a hero is selected --- src/graph/GraphView.tsx | 193 ++++------------------------------------ 1 file changed, 15 insertions(+), 178 deletions(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index 2e31bff6..fa570639 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -68,20 +68,11 @@ const HEIGHT = 760; const MIN_ZOOM = 0.55; const MAX_ZOOM = 2.4; const DRAG_THRESHOLD = 5; -const CAMERA_DURATION = 460; const COMPACT_VIEWPORT_QUERY = "(max-width: 640px)"; -const COMPACT_FOCUS_OFFSET_Y = -120; -const DESKTOP_FOCUS_OFFSET_X = 180; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); -const lerp = (from: number, to: number, progress: number) => - from + (to - from) * progress; - -const easeOutQuart = (progress: number) => - 1 - Math.pow(1 - progress, 4); - const normalizeAngle = (angle: number) => { const fullTurn = Math.PI * 2; const normalized = angle % fullTurn; @@ -148,43 +139,6 @@ export function GraphView({ const byId = useMemo(() => new Map(heroes.map((hero) => [hero.id, hero])), [heroes]); const initialCompactViewport = window.matchMedia(COMPACT_VIEWPORT_QUERY).matches; const [isCompactViewport, setIsCompactViewport] = useState(initialCompactViewport); - const [svgViewport, setSvgViewport] = useState({ width: WIDTH, height: HEIGHT }); - const selectedHero = selectedHeroId ? byId.get(selectedHeroId) : undefined; - const relatedHeroes = useMemo(() => { - if (!selectedHeroId) return []; - - const ids = new Set( - [...selectedRelations.incoming, ...selectedRelations.outgoing] - .flatMap((relationship) => [relationship.sourceHeroId, relationship.targetHeroId]) - .filter((heroId) => heroId !== selectedHeroId) - ); - - return [...ids].flatMap((heroId) => { - const hero = byId.get(heroId); - return hero ? [hero] : []; - }); - }, [byId, selectedHeroId, selectedRelations]); - - const visibleGraphSpan = useMemo( - () => visibleViewBoxForViewport( - svgViewport.width, - svgViewport.height, - WIDTH, - HEIGHT, - isCompactViewport ? "slice" : "meet" - ), - [isCompactViewport, svgViewport] - ); - const edgeLabelBounds = useMemo( - () => ({ - left: (WIDTH - visibleGraphSpan.width) / 2, - right: (WIDTH + visibleGraphSpan.width) / 2, - top: (HEIGHT - visibleGraphSpan.height) / 2, - bottom: (HEIGHT + visibleGraphSpan.height) / 2 - }), - [visibleGraphSpan.height, visibleGraphSpan.width] - ); - const overviewCamera = useMemo( () => calculateOverviewCamera(heroes, { @@ -193,35 +147,14 @@ export function GraphView({ }), [heroes] ); - const targetFocusScale = - selectedHero && isCompactViewport - ? calculateFocusScale(selectedHero, relatedHeroes, { - ...visibleGraphSpan, - offsetY: COMPACT_FOCUS_OFFSET_Y, - paddingX: 60, - paddingY: 54 - }) - : overviewCamera.scale; const initialCamera: CameraState = { - anchorX: - selectedHero && initialCompactViewport - ? selectedHero.x - : overviewCamera.anchorX, - anchorY: - selectedHero && initialCompactViewport - ? selectedHero.y - : overviewCamera.anchorY, - panX: - selectedHero && !initialCompactViewport - ? DESKTOP_FOCUS_OFFSET_X - : 0, - panY: selectedHero && initialCompactViewport ? COMPACT_FOCUS_OFFSET_Y : 0, + anchorX: overviewCamera.anchorX, + anchorY: overviewCamera.anchorY, + panX: 0, + panY: 0, zoom: 1, - focusScale: - selectedHero && initialCompactViewport - ? targetFocusScale - : overviewCamera.scale + focusScale: overviewCamera.scale }; const [camera, setCameraState] = useState(initialCamera); @@ -232,12 +165,6 @@ export function GraphView({ const [keyboardHeroId, setKeyboardHeroId] = useState(initialKeyboardHero?.id ?? null); const cameraRef = useRef(camera); const svgRef = useRef(null); - const animationFrameRef = useRef(null); - const previousCameraTargetRef = useRef( - `${selectedHeroId ?? ""}:${initialCompactViewport}:${ - selectedHero ? targetFocusScale.toFixed(4) : overviewCamera.scale.toFixed(4) - }:${overviewCamera.anchorX.toFixed(2)}:${overviewCamera.anchorY.toFixed(2)}` - ); const previousKeyboardSelectionRef = useRef(selectedHeroId); const dragRef = useRef(null); @@ -249,108 +176,18 @@ export function GraphView({ }); }; - const cancelCameraAnimation = () => { - if (animationFrameRef.current !== null) { - window.cancelAnimationFrame(animationFrameRef.current); - animationFrameRef.current = null; - } - }; - - useEffect(() => { - const cameraTargetKey = `${selectedHeroId ?? ""}:${isCompactViewport}:${ - selectedHero ? targetFocusScale.toFixed(4) : overviewCamera.scale.toFixed(4) - }:${overviewCamera.anchorX.toFixed(2)}:${overviewCamera.anchorY.toFixed(2)}`; - if (previousCameraTargetRef.current === cameraTargetKey) return; - previousCameraTargetRef.current = cameraTargetKey; - - cancelCameraAnimation(); - - const compactSelected = - selectedHeroId && isCompactViewport - ? byId.get(selectedHeroId) - : undefined; - const target: CameraState = { - anchorX: compactSelected?.x ?? overviewCamera.anchorX, - anchorY: compactSelected?.y ?? overviewCamera.anchorY, - panX: - selectedHeroId && !isCompactViewport - ? DESKTOP_FOCUS_OFFSET_X - : 0, - panY: compactSelected ? COMPACT_FOCUS_OFFSET_Y : 0, - zoom: 1, - focusScale: compactSelected ? targetFocusScale : overviewCamera.scale - }; - - if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) { - setCamera(target); - return; - } - - const from = cameraRef.current; - const startedAt = performance.now(); - - const tick = (now: number) => { - const rawProgress = clamp((now - startedAt) / CAMERA_DURATION, 0, 1); - const progress = easeOutQuart(rawProgress); - - setCamera({ - anchorX: lerp(from.anchorX, target.anchorX, progress), - anchorY: lerp(from.anchorY, target.anchorY, progress), - panX: lerp(from.panX, target.panX, progress), - panY: lerp(from.panY, target.panY, progress), - zoom: lerp(from.zoom, target.zoom, progress), - focusScale: lerp(from.focusScale, target.focusScale, progress) - }); - - if (rawProgress < 1) { - animationFrameRef.current = window.requestAnimationFrame(tick); - } else { - animationFrameRef.current = null; - } - }; - - animationFrameRef.current = window.requestAnimationFrame(tick); - - return cancelCameraAnimation; - }, [ - selectedHeroId, - selectedHero, - byId, - isCompactViewport, - targetFocusScale, - overviewCamera - ]); - - useEffect(() => cancelCameraAnimation, []); + const cancelCameraAnimation = () => {}; useEffect(() => { - const svg = svgRef.current; - if (!svg) return; - - const updateViewport = () => { - const rect = svg.getBoundingClientRect(); - if (rect.width <= 0 || rect.height <= 0) return; - - setSvgViewport((current) => { - if ( - Math.abs(current.width - rect.width) < 0.5 && - Math.abs(current.height - rect.height) < 0.5 - ) { - return current; - } - - return { width: rect.width, height: rect.height }; - }); - }; - - updateViewport(); - - if (typeof ResizeObserver === "undefined") return; - - const observer = new ResizeObserver(updateViewport); - observer.observe(svg); - return () => observer.disconnect(); - }, []); + setCamera((current) => ({ + ...current, + anchorX: overviewCamera.anchorX, + anchorY: overviewCamera.anchorY, + panX: 0, + panY: 0, + focusScale: overviewCamera.scale + })); + }, [overviewCamera]); useEffect(() => { const media = window.matchMedia(COMPACT_VIEWPORT_QUERY); From 429b91d3313a06e0b05fa11e86a1ff0008a8cba0 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:53:54 +0300 Subject: [PATCH 003/114] ui: make hero names and win rates use fixed graph geometry --- src/graph/GraphView.tsx | 311 ++++++---------------------------------- 1 file changed, 41 insertions(+), 270 deletions(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index fa570639..159e5f4a 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -13,24 +13,14 @@ import { import { usePortraitAsset } from "../components/PortraitProvider"; import { EDGE_LABEL_HEIGHT, - EDGE_LABEL_WIDTH, - layoutSourceAnchoredEdgeLabels + EDGE_LABEL_WIDTH } from "./edgeLabelLayout"; -import { - calculateFocusScale, - calculateOverviewCamera, - visibleViewBoxForViewport -} from "./focusCamera"; +import { calculateOverviewCamera } from "./focusCamera"; import { mergeVisibleRelationships, selectHoverRelationships, selectOverviewBackbone } from "./relationshipVisibility"; -import { layoutHeroLabels } from "./heroLabelLayout"; -import { - routeEdgeAroundObstacles, - routeSegments -} from "./edgeRouting"; interface GraphViewProps { heroes: Hero[]; @@ -73,56 +63,6 @@ const COMPACT_VIEWPORT_QUERY = "(max-width: 640px)"; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); -const normalizeAngle = (angle: number) => { - const fullTurn = Math.PI * 2; - const normalized = angle % fullTurn; - return normalized < 0 ? normalized + fullTurn : normalized; -}; - -const largestAngularGap = ( - center: { x: number; y: number }, - neighbors: Array<{ x: number; y: number }> -) => { - if (!neighbors.length) { - return { angle: -Math.PI / 2, halfGap: Math.PI }; - } - - const angles = neighbors - .map((neighbor) => - normalizeAngle(Math.atan2(neighbor.y - center.y, neighbor.x - center.x)) - ) - .sort((a, b) => a - b); - - if (angles.length === 1) { - return { - angle: normalizeAngle(angles[0] + Math.PI), - halfGap: Math.PI - }; - } - - let bestStart = angles[0]; - let bestGap = -1; - - for (let index = 0; index < angles.length; index += 1) { - const start = angles[index]; - const end = - index === angles.length - 1 - ? angles[0] + Math.PI * 2 - : angles[index + 1]; - const gap = end - start; - - if (gap > bestGap) { - bestGap = gap; - bestStart = start; - } - } - - return { - angle: normalizeAngle(bestStart + bestGap / 2), - halfGap: bestGap / 2 - }; -}; - export function GraphView({ heroes, relationships, @@ -253,58 +193,14 @@ export function GraphView({ y: HEIGHT / 2 + camera.panY + cameraScale * (y - camera.anchorY) }); - const heroLabelBounds = useMemo(() => { - const graphXForViewBoxX = (viewBoxX: number) => - camera.anchorX + - (viewBoxX - WIDTH / 2 - camera.panX) / cameraScale; - const graphYForViewBoxY = (viewBoxY: number) => - camera.anchorY + - (viewBoxY - HEIGHT / 2 - camera.panY) / cameraScale; - - let minViewBoxX = 0; - - if (selectedHeroId && !isCompactViewport) { - const outerScale = Math.min( - svgViewport.width / WIDTH, - svgViewport.height / HEIGHT - ); - - if (Number.isFinite(outerScale) && outerScale > 0) { - const outerOffsetX = (svgViewport.width - WIDTH * outerScale) / 2; - const cardWidth = svgViewport.width <= 820 ? 300 : 332; - const safeStageX = 16 + cardWidth + 12; - minViewBoxX = Math.max( - 0, - (safeStageX - outerOffsetX) / outerScale - ); - } - } - - return { - minX: graphXForViewBoxX(minViewBoxX), - maxX: graphXForViewBoxX(WIDTH), - minY: graphYForViewBoxY(0), - maxY: graphYForViewBoxY(HEIGHT) - }; - }, [ - camera.anchorX, - camera.anchorY, - camera.panX, - camera.panY, - cameraScale, - isCompactViewport, - selectedHeroId, - svgViewport.height, - svgViewport.width - ]); - const hoverRelationshipIds = new Set( hoverRelationships.map((relationship) => relationship.id) ); const radiusFor = (heroId: string) => { - if (heroId === selectedHeroId || activeIds.has(heroId)) return 14; - if (heroId === hoveredHeroId) return 18; + if (heroId === selectedHeroId) return 28; + if (activeIds.has(heroId)) return 19; + if (heroId === hoveredHeroId) return 19; return 14; }; @@ -314,8 +210,8 @@ export function GraphView({ const length = Math.hypot(dx, dy) || 1; const ux = dx / length; const uy = dy / length; - const sourcePadding = radiusFor(source.id) + 4; - const targetPadding = radiusFor(target.id) + 7; + const sourcePadding = radiusFor(source.id) + 5; + const targetPadding = radiusFor(target.id) + 9; return { x1: source.x + ux * sourcePadding, @@ -328,176 +224,51 @@ export function GraphView({ const labelWidthFor = (hero: Hero) => Math.max(42, hero.name.length * 6.7 + 14); - const labelHeroes = heroes - .filter( - (hero) => - hero.id === selectedHeroId || - activeIds.has(hero.id) || - hero.id === hoveredHeroId - ) - .sort((left, right) => { - const priority = (hero: Hero) => - hero.id === selectedHeroId ? 0 : activeIds.has(hero.id) ? 1 : 2; - return priority(left) - priority(right) || left.id.localeCompare(right.id); - }); - - const activeEdgeRoutes = new Map( - activeRelationships.flatMap((relationship) => { - const source = byId.get(relationship.sourceHeroId); - const target = byId.get(relationship.targetHeroId); - if (!source || !target) return []; - - const geometry = edgeGeometry(source, target); - const obstacles = [...activeIds].flatMap((heroId) => { - if ( - heroId === relationship.sourceHeroId || - heroId === relationship.targetHeroId - ) { - return []; - } - - const hero = byId.get(heroId); - return hero - ? [{ - id: hero.id, - x: hero.x, - y: hero.y, - radius: radiusFor(hero.id) + 4 - }] - : []; - }); - - return [[ - relationship.id, - routeEdgeAroundObstacles( - { x: geometry.x1, y: geometry.y1 }, - { x: geometry.x2, y: geometry.y2 }, - obstacles - ) - ] as const]; - }) + const labelHeroes = heroes.filter( + (hero) => + hero.id === selectedHeroId || + activeIds.has(hero.id) || + hero.id === hoveredHeroId ); - const activeEdgeSegments = activeRelationships.flatMap((relationship) => { - const route = activeEdgeRoutes.get(relationship.id); - return route ? routeSegments(route) : []; - }); - - const heroLabelPlacements = layoutHeroLabels( + const layoutMaxX = Math.max(...heroes.map((hero) => hero.x), WIDTH); + const fixedLabelPlacements = new Map( labelHeroes.map((hero) => { - let preferredAngle = - hero.x > WIDTH - 170 ? Math.PI : 0; - - if (hero.id === selectedHeroId) { - preferredAngle = largestAngularGap(hero, relatedHeroes).angle; - } else if (selectedHero && activeIds.has(hero.id)) { - preferredAngle = Math.atan2( - hero.y - selectedHero.y, - hero.x - selectedHero.x - ); - } - - return { - id: hero.id, - x: hero.x, - y: hero.y, - radius: radiusFor(hero.id), - width: labelWidthFor(hero), - height: 20, - preferredAngle - }; - }), - activeEdgeSegments, - [...activeIds].flatMap((heroId) => { - const hero = byId.get(heroId); - return hero - ? [{ - id: hero.id, - x: hero.x, - y: hero.y, - radius: radiusFor(hero.id) + 3 - }] - : []; - }), - heroLabelBounds + const width = labelWidthFor(hero); + const placeLeft = hero.x > layoutMaxX - 210; + const direction = placeLeft ? -1 : 1; + const centerOffset = radiusFor(hero.id) + 10 + width / 2; + + return [ + hero.id, + { + x: hero.x + direction * centerOffset, + y: hero.y, + angle: placeLeft ? Math.PI : 0 + } + ] as const; + }) ); - const heroLabelObstacles = labelHeroes.flatMap((hero) => { - const placement = heroLabelPlacements.get(hero.id); - if (!placement) return []; - - const point = projectGraphPoint(placement.x, placement.y); - return [{ - x: point.x, - y: point.y, - width: labelWidthFor(hero) * cameraScale, - height: 20 * cameraScale - }]; - }); - - const portraitRectObstacles = heroes.map((hero) => { - const point = projectGraphPoint(hero.x, hero.y); - const diameter = - (radiusFor(hero.id) * 2 + (activeIds.has(hero.id) ? 8 : 2)) * - cameraScale; - - return { - x: point.x, - y: point.y, - width: diameter, - height: diameter - }; - }); - - const edgeLabelPlacements = layoutSourceAnchoredEdgeLabels( + const EDGE_LABEL_T = 0.52; + const edgeLabelPlacements = new Map( activeRelationships.flatMap((relationship) => { const source = byId.get(relationship.sourceHeroId); const target = byId.get(relationship.targetHeroId); if (!source || !target) return []; - const route = activeEdgeRoutes.get(relationship.id); - const routePath = route ? routeSegments(route) : []; - if (!routePath.length) return []; - - const projectedPath = routePath.map((segment) => { - const start = projectGraphPoint(segment.x1, segment.y1); - const end = projectGraphPoint(segment.x2, segment.y2); - return { - x1: start.x, - y1: start.y, - x2: end.x, - y2: end.y - }; - }); - const sourcePoint = projectGraphPoint(source.x, source.y); - - return [{ - id: relationship.id, - segment: projectedPath[0], - segments: projectedPath, - preferredT: relationship.sourceHeroId === selectedHeroId ? 0.46 : 0.34, - source: { - x: sourcePoint.x, - y: sourcePoint.y, - radius: (radiusFor(source.id) + 7) * cameraScale + const geometry = edgeGeometry(source, target); + return [[ + relationship.id, + { + x: geometry.x1 + (geometry.x2 - geometry.x1) * EDGE_LABEL_T, + y: geometry.y1 + (geometry.y2 - geometry.y1) * EDGE_LABEL_T, + t: EDGE_LABEL_T, + offset: 0, + scale: isCompactViewport ? 0.8 : 1 } - }]; - }), - heroes.map((hero) => { - const point = projectGraphPoint(hero.x, hero.y); - return { - x: point.x, - y: point.y, - radius: - (radiusFor(hero.id) + (activeIds.has(hero.id) ? 7 : 2)) * - cameraScale - }; - }), - [...heroLabelObstacles, ...portraitRectObstacles], - { - sizeScale: isCompactViewport ? 0.8 : 1, - bounds: edgeLabelBounds - } + ] as const]; + }) ); const handlePointerDown = (event: ReactPointerEvent) => { From 34e3817b5b0657a50b2179cb4d263ed318006b80 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:54:46 +0300 Subject: [PATCH 004/114] ui: render every highlighted relationship as one straight line --- src/graph/GraphView.tsx | 47 +++++++++-------------------------------- 1 file changed, 10 insertions(+), 37 deletions(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index 159e5f4a..98896b9f 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -258,11 +258,16 @@ export function GraphView({ if (!source || !target) return []; const geometry = edgeGeometry(source, target); + const point = projectGraphPoint( + geometry.x1 + (geometry.x2 - geometry.x1) * EDGE_LABEL_T, + geometry.y1 + (geometry.y2 - geometry.y1) * EDGE_LABEL_T + ); + return [[ relationship.id, { - x: geometry.x1 + (geometry.x2 - geometry.x1) * EDGE_LABEL_T, - y: geometry.y1 + (geometry.y2 - geometry.y1) * EDGE_LABEL_T, + x: point.x, + y: point.y, t: EDGE_LABEL_T, offset: 0, scale: isCompactViewport ? 0.8 : 1 @@ -448,30 +453,12 @@ export function GraphView({ isMatchup ? "edge-matchup" : "" ].filter(Boolean).join(" "); const geometry = edgeGeometry(source, target); - const route = isActive - ? activeEdgeRoutes.get(relationship.id) - : undefined; const markerEnd = isIncoming ? "url(#arrow-incoming)" : isOutgoing ? "url(#arrow-outgoing)" : undefined; - if (route?.detoured) { - return ( - `${point.x},${point.y}`).join(" ")} - fill="none" - markerEnd={markerEnd} - data-source-hero={relationship.sourceHeroId} - data-target-hero={relationship.targetHeroId} - data-route-detoured="true" - /> - ); - } - return ( - {labelPlacement.leader && ( - - )} Date: Sat, 26 Sep 2026 12:55:19 +0300 Subject: [PATCH 005/114] data: spread heroes across the organic global canvas --- public/data/current-matchups.json | 524 +++++++++++++++--------------- 1 file changed, 262 insertions(+), 262 deletions(-) diff --git a/public/data/current-matchups.json b/public/data/current-matchups.json index 6fac709c..cb1910dc 100644 --- a/public/data/current-matchups.json +++ b/public/data/current-matchups.json @@ -42,14 +42,14 @@ }, "layoutMetrics": { "edgeCount": 477, - "medianDistance": 260.24, - "p90Distance": 437.43, - "p95Distance": 482.24, - "maxDistance": 700.79, - "weightedMeanDistance": 226.1, - "minimumNodeDistance": 67.73, - "medianNearestNodeDistance": 67.78, - "maxNearestNodeDistance": 102.77, + "medianDistance": 330.81, + "p90Distance": 556.06, + "p95Distance": 613.02, + "maxDistance": 890.83, + "weightedMeanDistance": 287.42, + "minimumNodeDistance": 86.1, + "medianNearestNodeDistance": 86.16, + "maxNearestNodeDistance": 130.64, "coveredNodeCount": 127, "isolatedNodeCount": 0 }, @@ -53137,512 +53137,512 @@ "evidenceObservations": [], "positions": { "abaddon": { - "x": 251.38, - "y": 274.59 + "x": 506.84, + "y": 456 }, "abyssal_underlord": { - "x": 60.74, - "y": 308.74 + "x": 264.5, + "y": 499.42 }, "alchemist": { - "x": 840.65, - "y": 148.54 + "x": 1255.91, + "y": 295.77 }, "ancient_apparition": { - "x": 857.99, - "y": 83.0 + "x": 1277.95, + "y": 212.46 }, "antimage": { - "x": 454.34, - "y": 284.14 + "x": 764.84, + "y": 468.14 }, "arc_warden": { - "x": 429.47, - "y": 483.05 + "x": 733.22, + "y": 721 }, "axe": { - "x": 235.4, - "y": 340.46 + "x": 486.53, + "y": 539.74 }, "bane": { - "x": 432.26, - "y": 550.85 + "x": 736.77, + "y": 807.18 }, "batrider": { - "x": 290.29, - "y": 380.15 + "x": 556.3, + "y": 590.19 }, "beastmaster": { - "x": 355.55, - "y": 107.88 + "x": 639.26, + "y": 244.08 }, "bloodseeker": { - "x": 268.52, - "y": 557.07 + "x": 528.63, + "y": 815.09 }, "bounty_hunter": { - "x": 391.07, - "y": 308.36 + "x": 684.41, + "y": 498.93 }, "brewmaster": { - "x": 478.93, - "y": 189.51 + "x": 796.1, + "y": 347.85 }, "bristleback": { - "x": 569.47, - "y": 361.98 + "x": 911.19, + "y": 567.09 }, "broodmother": { - "x": 98.77, - "y": 432.16 + "x": 312.84, + "y": 656.31 }, "centaur": { - "x": 490.7, - "y": 587.18 + "x": 811.06, + "y": 853.36 }, "chaos_knight": { - "x": 543.98, - "y": 211.86 + "x": 878.79, + "y": 376.26 }, "chen": { - "x": 81.05, - "y": 104.46 + "x": 290.32, + "y": 239.74 }, "clinkz": { - "x": 492.33, - "y": 519.4 + "x": 813.13, + "y": 767.2 }, "crystal_maiden": { - "x": 458.94, - "y": 421.97 + "x": 770.69, + "y": 643.35 }, "dark_seer": { - "x": 597.51, - "y": 165.52 + "x": 946.83, + "y": 317.36 }, "dark_willow": { - "x": 1046.83, - "y": 587.36 + "x": 1518, + "y": 853.59 }, "dawnbreaker": { - "x": 346.66, - "y": 257.06 + "x": 627.96, + "y": 433.72 }, "dazzle": { - "x": 219.66, - "y": 214.7 + "x": 466.52, + "y": 379.87 }, "death_prophet": { - "x": 180.89, - "y": 598.14 + "x": 417.23, + "y": 867.3 }, "disruptor": { - "x": 935.67, - "y": 530.95 + "x": 1376.7, + "y": 781.89 }, "doom_bringer": { - "x": 755.66, - "y": 699.35 + "x": 1147.87, + "y": 995.95 }, "dragon_knight": { - "x": 860.43, - "y": 663.73 + "x": 1281.06, + "y": 950.67 }, "drow_ranger": { - "x": 868.17, - "y": 543.96 + "x": 1290.89, + "y": 798.42 }, "earth_spirit": { - "x": 1140.68, - "y": 484.37 + "x": 1637.31, + "y": 722.67 }, "earthshaker": { - "x": 171.19, - "y": 318.73 + "x": 404.9, + "y": 512.11 }, "elder_titan": { - "x": 1039.32, - "y": 431.66 + "x": 1508.46, + "y": 655.67 }, "ember_spirit": { - "x": 790.38, - "y": 265.89 + "x": 1192.01, + "y": 444.94 }, "enchantress": { - "x": 836.36, - "y": 216.13 + "x": 1250.46, + "y": 381.69 }, "enigma": { - "x": 881.99, - "y": 411.93 + "x": 1308.46, + "y": 630.59 }, "faceless_void": { - "x": 189.34, - "y": 468.15 + "x": 427.97, + "y": 702.06 }, "furion": { - "x": 753.71, - "y": 323.08 + "x": 1145.39, + "y": 517.64 }, "grimstroke": { - "x": 593.4, - "y": 688.93 + "x": 941.61, + "y": 982.71 }, "gyrocopter": { - "x": 119.08, - "y": 367.01 + "x": 338.66, + "y": 573.49 }, "hoodwink": { - "x": 24.0, - "y": 391.94 + "x": 217.8, + "y": 605.18 }, "huskar": { - "x": 821.47, - "y": 326.2 + "x": 1231.53, + "y": 521.61 }, "invoker": { - "x": 500.29, - "y": 47.28 + "x": 823.25, + "y": 167.05 }, "jakiro": { - "x": 1126.96, - "y": 415.15 + "x": 1619.86, + "y": 634.68 }, "juggernaut": { - "x": 203.95, - "y": 534.31 + "x": 446.55, + "y": 786.16 }, "keeper_of_the_light": { - "x": 936.62, - "y": 138.55 + "x": 1377.91, + "y": 283.07 }, "kez": { - "x": 719.61, - "y": 381.64 + "x": 1102.05, + "y": 592.08 }, "kunkka": { - "x": 468.04, - "y": 118.9 + "x": 782.25, + "y": 258.09 }, "largo": { - "x": 787.47, - "y": 106.56 + "x": 1188.31, + "y": 242.41 }, "legion_commander": { - "x": 940.17, - "y": 302.65 + "x": 1382.42, + "y": 491.67 }, "leshrac": { - "x": 317.36, - "y": 195.93 + "x": 590.71, + "y": 356.01 }, "lich": { - "x": 925.87, - "y": 598.09 + "x": 1364.24, + "y": 867.23 }, "life_stealer": { - "x": 246.77, - "y": 432.13 + "x": 500.98, + "y": 656.27 }, "lina": { - "x": 249.73, - "y": 93.11 + "x": 504.74, + "y": 225.31 }, "lion": { - "x": 994.13, - "y": 482.31 + "x": 1451.01, + "y": 720.06 }, "lone_druid": { - "x": 154.5, - "y": 176.34 + "x": 383.69, + "y": 331.11 }, "luna": { - "x": 259.81, - "y": 160.11 + "x": 517.56, + "y": 310.48 }, "lycan": { - "x": 685.55, - "y": 469.25 + "x": 1058.75, + "y": 703.45 }, "magnataur": { - "x": 1067.62, - "y": 360.2 + "x": 1544.43, + "y": 564.83 }, "marci": { - "x": 945.72, - "y": 434.89 + "x": 1389.47, + "y": 659.78 }, "mars": { - "x": 576.06, - "y": 623.33 + "x": 919.57, + "y": 899.32 }, "medusa": { - "x": 730.62, - "y": 143.47 + "x": 1116.04, + "y": 289.33 }, "meepo": { - "x": 526.28, - "y": 414.23 + "x": 856.29, + "y": 633.51 }, "mirana": { - "x": 918.4, - "y": 203.84 + "x": 1354.75, + "y": 366.07 }, "monkey_king": { - "x": 727.19, - "y": 57.07 + "x": 1111.68, + "y": 179.5 }, "morphling": { - "x": 411.45, - "y": 69.41 + "x": 710.32, + "y": 195.18 }, "muerta": { - "x": 608.6, - "y": 232.36 + "x": 960.93, + "y": 402.32 }, "naga_siren": { - "x": 996.84, - "y": 665.83 + "x": 1454.46, + "y": 953.34 }, "necrolyte": { - "x": 662.97, - "y": 147.67 + "x": 1030.05, + "y": 294.67 }, "nevermore": { - "x": 634.72, - "y": 343.71 + "x": 994.14, + "y": 543.87 }, "night_stalker": { - "x": 417.37, - "y": 616.94 + "x": 717.84, + "y": 891.19 }, "nyx_assassin": { - "x": 580.6, - "y": 99.86 + "x": 925.34, + "y": 233.89 }, "obsidian_destroyer": { - "x": 521.59, - "y": 275.86 + "x": 850.33, + "y": 457.62 }, "ogre_magi": { - "x": 324.74, - "y": 438.51 + "x": 600.09, + "y": 664.38 }, "omniknight": { - "x": 1065.8, - "y": 522.21 + "x": 1542.12, + "y": 770.78 }, "oracle": { - "x": 312.34, - "y": 315.54 + "x": 584.33, + "y": 508.06 }, "pangolier": { - "x": 311.46, - "y": 685.73 + "x": 583.21, + "y": 978.64 }, "phantom_assassin": { - "x": 888.71, - "y": 479.34 + "x": 1317, + "y": 716.28 }, "phantom_lancer": { - "x": 438.51, - "y": 357.32 + "x": 744.72, + "y": 561.17 }, "phoenix": { - "x": 884.89, - "y": 263.49 + "x": 1312.15, + "y": 441.89 }, "primal_beast": { - "x": 351.07, - "y": 563.64 + "x": 633.56, + "y": 823.44 }, "puck": { - "x": 652.82, - "y": 721.62 + "x": 1017.14, + "y": 1024.26 }, "pudge": { - "x": 1097.83, - "y": 281.64 + "x": 1582.83, + "y": 464.97 }, "pugna": { - "x": 886.74, - "y": 344.31 + "x": 1314.5, + "y": 544.63 }, "queenofpain": { - "x": 380.37, - "y": 170.95 + "x": 670.81, + "y": 324.26 }, "rattletrap": { - "x": 530.42, - "y": 145.43 + "x": 861.55, + "y": 291.82 }, "razor": { - "x": 504.37, - "y": 341.38 + "x": 828.44, + "y": 540.91 }, "riki": { - "x": 474.77, - "y": 653.02 + "x": 790.81, + "y": 937.06 }, "ringmaster": { - "x": 404.99, - "y": 695.56 + "x": 702.11, + "y": 991.14 }, "rubick": { - "x": 48.07, - "y": 242.07 + "x": 248.39, + "y": 414.67 }, "sand_king": { - "x": 391.16, - "y": 425.2 + "x": 684.53, + "y": 647.46 }, "shadow_demon": { - "x": 693.94, - "y": 596.44 + "x": 1069.42, + "y": 865.14 }, "shadow_shaman": { - "x": 809.64, - "y": 509.82 + "x": 1216.49, + "y": 755.03 }, "shredder": { - "x": 546.64, - "y": 478.88 + "x": 882.17, + "y": 715.69 }, "silencer": { - "x": 281.17, - "y": 490.53 + "x": 544.71, + "y": 730.5 }, "skeleton_king": { - "x": 750.69, - "y": 441.83 + "x": 1141.56, + "y": 668.6 }, "skywrath_mage": { - "x": 832.79, - "y": 601.76 + "x": 1245.92, + "y": 871.9 }, "slardar": { - "x": 188.45, - "y": 397.46 + "x": 426.84, + "y": 612.19 }, "slark": { - "x": 962.44, - "y": 369.12 + "x": 1410.73, + "y": 576.17 }, "snapfire": { - "x": 774.25, - "y": 567.63 + "x": 1171.5, + "y": 828.51 }, "sniper": { - "x": 363.07, - "y": 496.85 + "x": 648.82, + "y": 738.54 }, "spectre": { - "x": 590.82, - "y": 557.17 + "x": 938.33, + "y": 815.22 }, "spirit_breaker": { - "x": 155.49, - "y": 252.76 + "x": 384.94, + "y": 428.25 }, "storm_spirit": { - "x": 658.2, - "y": 654.03 + "x": 1023.98, + "y": 938.34 }, "sven": { - "x": 421.19, - "y": 225.02 + "x": 722.7, + "y": 392.99 }, "techies": { - "x": 1007.08, - "y": 313.92 + "x": 1467.47, + "y": 506 }, "templar_assassin": { - "x": 657.06, - "y": 407.74 + "x": 1022.53, + "y": 625.26 }, "terrorblade": { - "x": 657.12, - "y": 279.75 + "x": 1022.61, + "y": 462.56 }, "tidehunter": { - "x": 1176.0, - "y": 340.78 + "x": 1682.2, + "y": 540.14 }, "tinker": { - "x": 740.48, - "y": 508.91 + "x": 1128.58, + "y": 753.87 }, "tiny": { - "x": 356.79, - "y": 366.8 + "x": 640.83, + "y": 573.22 }, "treant": { - "x": 821.77, - "y": 443.11 + "x": 1231.91, + "y": 670.22 }, "troll_warlord": { - "x": 592.56, - "y": 428.44 + "x": 940.54, + "y": 651.58 }, "tusk": { - "x": 1036.04, - "y": 186.05 + "x": 1504.29, + "y": 343.45 }, "undying": { - "x": 722.7, - "y": 262.72 + "x": 1105.97, + "y": 440.92 }, "ursa": { - "x": 586.27, - "y": 296.31 + "x": 932.55, + "y": 483.61 }, "vengefulspirit": { - "x": 675.06, - "y": 214.37 + "x": 1045.42, + "y": 379.45 }, "venomancer": { - "x": 289.87, - "y": 621.47 + "x": 555.77, + "y": 896.95 }, "viper": { - "x": 758.58, - "y": 205.24 + "x": 1151.58, + "y": 367.85 }, "visage": { - "x": 787.31, - "y": 384.79 + "x": 1188.11, + "y": 596.09 }, "void_spirit": { - "x": 751.84, - "y": 631.68 + "x": 1143.02, + "y": 909.93 }, "warlock": { - "x": 601.1, - "y": 35.24 + "x": 951.4, + "y": 151.75 }, "weaver": { - "x": 98.41, - "y": 500.01 + "x": 312.39, + "y": 742.56 }, "windrunner": { - "x": 666.72, - "y": 534.36 + "x": 1034.81, + "y": 786.22 }, "winter_wyvern": { - "x": 660.58, - "y": 79.91 + "x": 1027.01, + "y": 208.53 }, "wisp": { - "x": 535.82, - "y": 724.76 + "x": 868.42, + "y": 1028.25 }, "witch_doctor": { - "x": 989.3, - "y": 246.36 + "x": 1444.87, + "y": 420.12 }, "zuus": { - "x": 612.96, - "y": 493.06 + "x": 966.47, + "y": 733.72 } } } From 5c638bdc175b9d47e0408bce4147b3352d143739 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:56:28 +0300 Subject: [PATCH 006/114] test: expect the larger organic virtual canvas --- pipeline/tests/test_layout.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/pipeline/tests/test_layout.py b/pipeline/tests/test_layout.py index 96f2fddc..85a12938 100644 --- a/pipeline/tests/test_layout.py +++ b/pipeline/tests/test_layout.py @@ -122,12 +122,12 @@ def test_spread_layout_uses_more_canvas_without_changing_topology(self) -> None: ) self.assertGreater(after, before) - self.assertAlmostEqual(spread["center"][0], 600.0, places=2) - self.assertAlmostEqual(spread["center"][1], 380.0, places=2) - self.assertGreaterEqual(min(x for x, _ in spread.values()), 24.0) - self.assertLessEqual(max(x for x, _ in spread.values()), 1176.0) - self.assertGreaterEqual(min(y for _, y in spread.values()), 24.0) - self.assertLessEqual(max(y for _, y in spread.values()), 736.0) + self.assertAlmostEqual(spread["center"][0], 950.0, places=2) + self.assertAlmostEqual(spread["center"][1], 590.0, places=2) + self.assertGreaterEqual(min(x for x, _ in spread.values()), 60.0) + self.assertLessEqual(max(x for x, _ in spread.values()), 1840.0) + self.assertGreaterEqual(min(y for _, y in spread.values()), 60.0) + self.assertLessEqual(max(y for _, y in spread.values()), 1120.0) def test_spread_layout_preserves_relative_angles(self) -> None: positions = { From 01bb8c5cb4e043d6d6781d9ab8727a58f2c8e17d Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 12:56:33 +0300 Subject: [PATCH 007/114] test: raise spacing gates for the organic global layout --- pipeline/tests/test_current_bundle.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pipeline/tests/test_current_bundle.py b/pipeline/tests/test_current_bundle.py index 41aabd4d..518e5c54 100644 --- a/pipeline/tests/test_current_bundle.py +++ b/pipeline/tests/test_current_bundle.py @@ -205,9 +205,9 @@ def test_committed_snapshot_layout_quality(self) -> None: self.assertEqual(metrics["coveredNodeCount"], 127) self.assertEqual(metrics["isolatedNodeCount"], 0) - self.assertGreaterEqual(metrics["minimumNodeDistance"], 67.0) - self.assertGreaterEqual(metrics["medianNearestNodeDistance"], 67.0) - self.assertLessEqual(metrics["maxNearestNodeDistance"], 96.0) + self.assertGreaterEqual(metrics["minimumNodeDistance"], 84.0) + self.assertGreaterEqual(metrics["medianNearestNodeDistance"], 84.0) + self.assertLessEqual(metrics["maxNearestNodeDistance"], 135.0) def test_generate_emits_741f_production_contract(self) -> None: repo_root = Path(__file__).resolve().parents[2] From a750bf75e0b4f69bad2484976040e7f10a5493e0 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:03:55 +0300 Subject: [PATCH 008/114] ui: match reference emphasis and stable label layering --- src/graph/GraphView.tsx | 128 ++++++++++++++++++++++------------------ 1 file changed, 71 insertions(+), 57 deletions(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index 98896b9f..f1a29fef 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -83,7 +83,8 @@ export function GraphView({ () => calculateOverviewCamera(heroes, { width: WIDTH, - height: HEIGHT + height: HEIGHT, + minScale: 0.84 }), [heroes] ); @@ -198,9 +199,9 @@ export function GraphView({ ); const radiusFor = (heroId: string) => { - if (heroId === selectedHeroId) return 28; - if (activeIds.has(heroId)) return 19; - if (heroId === hoveredHeroId) return 19; + if (heroId === selectedHeroId) return 38; + if (activeIds.has(heroId)) return 22; + if (heroId === hoveredHeroId) return 20; return 14; }; @@ -222,7 +223,9 @@ export function GraphView({ }; const labelWidthFor = (hero: Hero) => - Math.max(42, hero.name.length * 6.7 + 14); + hero.id === selectedHeroId + ? Math.max(82, hero.name.length * 10.2 + 20) + : Math.max(48, hero.name.length * 7.2 + 14); const labelHeroes = heroes.filter( (hero) => @@ -237,7 +240,10 @@ export function GraphView({ const width = labelWidthFor(hero); const placeLeft = hero.x > layoutMaxX - 210; const direction = placeLeft ? -1 : 1; - const centerOffset = radiusFor(hero.id) + 10 + width / 2; + const screenGap = hero.id === selectedHeroId ? 12 : 9; + const centerOffset = + radiusFor(hero.id) + + (screenGap + width / 2) / Math.max(cameraScale, 0.001); return [ hero.id, @@ -250,25 +256,24 @@ export function GraphView({ }) ); - const EDGE_LABEL_T = 0.52; const edgeLabelPlacements = new Map( activeRelationships.flatMap((relationship) => { const source = byId.get(relationship.sourceHeroId); const target = byId.get(relationship.targetHeroId); if (!source || !target) return []; + const t = + relationship.sourceHeroId === selectedHeroId + ? 0.64 + : 0.36; const geometry = edgeGeometry(source, target); - const point = projectGraphPoint( - geometry.x1 + (geometry.x2 - geometry.x1) * EDGE_LABEL_T, - geometry.y1 + (geometry.y2 - geometry.y1) * EDGE_LABEL_T - ); return [[ relationship.id, { - x: point.x, - y: point.y, - t: EDGE_LABEL_T, + x: geometry.x1 + (geometry.x2 - geometry.x1) * t, + y: geometry.y1 + (geometry.y2 - geometry.y1) * t, + t, offset: 0, scale: isCompactViewport ? 0.8 : 1 } @@ -476,6 +481,53 @@ export function GraphView({ })} + + {heroes.map((hero) => { const isSelected = hero.id === selectedHeroId; @@ -617,9 +669,12 @@ export function GraphView({ return ( - ); From ed31f74d92325f2daeb1ab2f08d3656561aecba6 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:04:10 +0300 Subject: [PATCH 009/114] ui: strengthen selected hero and label emphasis --- src/styles/global.css | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/styles/global.css b/src/styles/global.css index fb7a70c9..4ae88fb2 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -468,7 +468,7 @@ button:focus-visible, input:focus-visible, [role="button"]:focus-visible { .hero-dimmed { opacity: .14; } .hero-active { opacity: 1; } .hero-active .node-ring { stroke: #8b949e; } -.hero-selected .node-ring { stroke: var(--selected); stroke-width: 2.4; } +.hero-selected .node-ring { stroke: var(--selected); stroke-width: 4; } .hero-matchup .node-ring { stroke-width: 2.4; } .hero-hovered:not(.hero-selected) .node-ring { stroke: var(--secondary); } @@ -494,7 +494,7 @@ button:focus-visible, input:focus-visible, [role="button"]:focus-visible { .hero-label { fill: var(--text); - font: 600 11px "Mona Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; + font: 600 12px "Mona Sans", -apple-system, BlinkMacSystemFont, "Segoe UI", "Noto Sans", Helvetica, Arial, sans-serif; paint-order: stroke fill; stroke: var(--canvas); stroke-width: 2px; @@ -502,6 +502,11 @@ button:focus-visible, input:focus-visible, [role="button"]:focus-visible { pointer-events: none; } +.hero-label-selected .hero-label { + font-size: 18px; + font-weight: 700; +} + .empty-guidance { position: absolute; left: 18px; From 399b269a2a01424bac5d8badc15e3dc3ed3acaef Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:09:07 +0300 Subject: [PATCH 010/114] ui: reframe the same global layout only on compact screens --- src/graph/GraphView.tsx | 137 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 123 insertions(+), 14 deletions(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index f1a29fef..c265e6b3 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -15,7 +15,11 @@ import { EDGE_LABEL_HEIGHT, EDGE_LABEL_WIDTH } from "./edgeLabelLayout"; -import { calculateOverviewCamera } from "./focusCamera"; +import { + calculateFocusScale, + calculateOverviewCamera, + visibleViewBoxForViewport +} from "./focusCamera"; import { mergeVisibleRelationships, selectHoverRelationships, @@ -79,6 +83,36 @@ export function GraphView({ const byId = useMemo(() => new Map(heroes.map((hero) => [hero.id, hero])), [heroes]); const initialCompactViewport = window.matchMedia(COMPACT_VIEWPORT_QUERY).matches; const [isCompactViewport, setIsCompactViewport] = useState(initialCompactViewport); + const [svgViewport, setSvgViewport] = useState({ width: WIDTH, height: HEIGHT }); + const selectedHero = selectedHeroId ? byId.get(selectedHeroId) : undefined; + const relatedHeroes = useMemo(() => { + if (!selectedHeroId) return []; + + const ids = new Set( + [...selectedRelations.incoming, ...selectedRelations.outgoing] + .flatMap((relationship) => [ + relationship.sourceHeroId, + relationship.targetHeroId + ]) + .filter((heroId) => heroId !== selectedHeroId) + ); + + return [...ids].flatMap((heroId) => { + const hero = byId.get(heroId); + return hero ? [hero] : []; + }); + }, [byId, selectedHeroId, selectedRelations.incoming, selectedRelations.outgoing]); + const visibleGraphSpan = useMemo( + () => + visibleViewBoxForViewport( + svgViewport.width, + svgViewport.height, + WIDTH, + HEIGHT, + isCompactViewport ? "slice" : "meet" + ), + [isCompactViewport, svgViewport.height, svgViewport.width] + ); const overviewCamera = useMemo( () => calculateOverviewCamera(heroes, { @@ -89,13 +123,34 @@ export function GraphView({ [heroes] ); + const compactFocusScale = + selectedHero && initialCompactViewport + ? calculateFocusScale(selectedHero, relatedHeroes, { + ...visibleGraphSpan, + offsetY: -92, + paddingX: 52, + paddingY: 48, + minScale: 0.42, + maxScale: 0.9 + }) + : overviewCamera.scale; + const initialCamera: CameraState = { - anchorX: overviewCamera.anchorX, - anchorY: overviewCamera.anchorY, + anchorX: + selectedHero && initialCompactViewport + ? selectedHero.x + : overviewCamera.anchorX, + anchorY: + selectedHero && initialCompactViewport + ? selectedHero.y + : overviewCamera.anchorY, panX: 0, - panY: 0, + panY: selectedHero && initialCompactViewport ? -92 : 0, zoom: 1, - focusScale: overviewCamera.scale + focusScale: + selectedHero && initialCompactViewport + ? compactFocusScale + : overviewCamera.scale }; const [camera, setCameraState] = useState(initialCamera); @@ -120,15 +175,69 @@ export function GraphView({ const cancelCameraAnimation = () => {}; useEffect(() => { - setCamera((current) => ({ - ...current, - anchorX: overviewCamera.anchorX, - anchorY: overviewCamera.anchorY, - panX: 0, - panY: 0, - focusScale: overviewCamera.scale - })); - }, [overviewCamera]); + if (isCompactViewport && selectedHero) { + const nextScale = calculateFocusScale(selectedHero, relatedHeroes, { + ...visibleGraphSpan, + offsetY: -92, + paddingX: 52, + paddingY: 48, + minScale: 0.42, + maxScale: 0.9 + }); + setCamera((current) => ({ + ...current, + anchorX: selectedHero.x, + anchorY: selectedHero.y, + panX: 0, + panY: -92, + zoom: 1, + focusScale: nextScale + })); + return; + } + + if (!selectedHeroId) { + setCamera((current) => ({ + ...current, + anchorX: overviewCamera.anchorX, + anchorY: overviewCamera.anchorY, + panX: 0, + panY: 0, + zoom: 1, + focusScale: overviewCamera.scale + })); + } + }, [ + isCompactViewport, + overviewCamera, + relatedHeroes, + selectedHero, + selectedHeroId, + visibleGraphSpan + ]); + + useEffect(() => { + const svg = svgRef.current; + if (!svg) return; + + const updateViewport = () => { + const rect = svg.getBoundingClientRect(); + if (rect.width <= 0 || rect.height <= 0) return; + setSvgViewport((current) => + Math.abs(current.width - rect.width) < 0.5 && + Math.abs(current.height - rect.height) < 0.5 + ? current + : { width: rect.width, height: rect.height } + ); + }; + + updateViewport(); + if (typeof ResizeObserver === "undefined") return; + + const observer = new ResizeObserver(updateViewport); + observer.observe(svg); + return () => observer.disconnect(); + }, []); useEffect(() => { const media = window.matchMedia(COMPACT_VIEWPORT_QUERY); From 9bac4d5a0f72dd11b5cac42c3b453e7c50446454 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:09:22 +0300 Subject: [PATCH 011/114] ui: spread fixed win-rate pills toward outer endpoints --- src/graph/GraphView.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index c265e6b3..861a6bf8 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -373,8 +373,8 @@ export function GraphView({ const t = relationship.sourceHeroId === selectedHeroId - ? 0.64 - : 0.36; + ? 0.72 + : 0.28; const geometry = edgeGeometry(source, target); return [[ From f2d08becb96fbe10df11994c9f205ba9df40b843 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 13:12:44 +0300 Subject: [PATCH 012/114] test: enforce one stable global focus layout --- tests/e2e/app.spec.ts | 243 +++++++++++++----------------------------- 1 file changed, 72 insertions(+), 171 deletions(-) diff --git a/tests/e2e/app.spec.ts b/tests/e2e/app.spec.ts index 10aa4c3c..3ea6cd28 100644 --- a/tests/e2e/app.spec.ts +++ b/tests/e2e/app.spec.ts @@ -66,30 +66,33 @@ test("hovered hero label renders above every portrait node", async ({ page }) => expect(layerOrder!.labels).toBeGreaterThan(layerOrder!.nodes); }); -test("win-rate badges render above every camera edge", async ({ page }) => { +test("focus layers keep edges behind badges, portraits, and hero names", async ({ page }) => { await page.goto("/?hero=viper"); - await expect( - page.getByRole("group", { name: "Dota 2 hero counter relationships" }) - ).toBeVisible(); await expect(page.locator(".edge-label").first()).toBeVisible(); const order = await page.evaluate(() => { - const graph = document.querySelector("svg.graph"); - if (!graph) return null; + const camera = document.querySelector(".graph-camera"); + if (!camera) return null; - const children = Array.from(graph.children); - const camera = children.find((child) => child.classList.contains("graph-camera")); - const labels = children.find((child) => child.classList.contains("edge-label-layer")); - if (!camera || !labels) return null; + const children = Array.from(camera.children); + const edges = children.find((child) => child.classList.contains("edges")); + const badges = children.find((child) => child.classList.contains("edge-label-layer")); + const nodes = children.find((child) => child.classList.contains("nodes")); + const names = children.find((child) => child.classList.contains("hero-label-layer")); + if (!edges || !badges || !nodes || !names) return null; return { - camera: children.indexOf(camera), - labels: children.indexOf(labels) + edges: children.indexOf(edges), + badges: children.indexOf(badges), + nodes: children.indexOf(nodes), + names: children.indexOf(names) }; }); expect(order).not.toBeNull(); - expect(order!.labels).toBeGreaterThan(order!.camera); + expect(order!.badges).toBeGreaterThan(order!.edges); + expect(order!.nodes).toBeGreaterThan(order!.badges); + expect(order!.names).toBeGreaterThan(order!.nodes); }); test("win-rate badges keep native screen scale after camera zoom", async ({ page }) => { @@ -245,7 +248,7 @@ test("focus keeps the same graph and only highlights selected relationships", as await expect(page.getByLabel("Terrorblade counter summary")).not.toContainText(/\/5/); }); -test("desktop focus keeps hero names clear of the card and viewport edges", async ({ page }) => { +test("desktop focus keeps fixed hero names inside the graph viewport", async ({ page }) => { await page.setViewportSize({ width: 1366, height: 768 }); for (const heroId of [ @@ -300,108 +303,51 @@ test("desktop focus keeps hero names clear of the card and viewport edges", asyn return { cardOverlaps, outsideViewport }; }); - expect(geometry.cardOverlaps, heroId).toEqual([]); expect(geometry.outsideViewport, heroId).toEqual([]); } }); -test("focused hero names stay clear of active relationship lines", async ({ page }) => { +test("focused hero names stay fixed beside their unchanged portraits", async ({ page }) => { + await page.setViewportSize({ width: 1366, height: 768 }); await page.goto("/?hero=crystal-maiden"); - await page.waitForTimeout(520); - - const intersections = await page.evaluate(() => { - const edges = [...document.querySelectorAll(".edge-active")]; - - const transformPoint = (x: number, y: number, matrix: DOMMatrix) => ({ - x: matrix.a * x + matrix.c * y + matrix.e, - y: matrix.b * x + matrix.d * y + matrix.f - }); - - const screenSegmentsFor = (edge: SVGElement) => { - const matrix = edge.getScreenCTM(); - if (!matrix) return []; + await page.waitForTimeout(120); - const localPoints: Array<{ x: number; y: number }> = []; - if (edge instanceof SVGLineElement) { - localPoints.push( - { x: edge.x1.baseVal.value, y: edge.y1.baseVal.value }, - { x: edge.x2.baseVal.value, y: edge.y2.baseVal.value } + const geometry = await page.evaluate(() => { + const rows = [...document.querySelectorAll(".hero-label-group")] + .map((label) => { + const heroId = label.dataset.heroLabel ?? ""; + const node = document.querySelector(`#graph-hero-${heroId}`); + const portrait = node?.querySelector(".hero-portrait-node"); + if (!node || !portrait) return null; + + const labelRect = label.getBoundingClientRect(); + const portraitRect = portrait.getBoundingClientRect(); + const gapRight = labelRect.left - portraitRect.right; + const gapLeft = portraitRect.left - labelRect.right; + const verticalDelta = Math.abs( + (labelRect.top + labelRect.bottom) / 2 - + (portraitRect.top + portraitRect.bottom) / 2 ); - } else if (edge instanceof SVGPolylineElement) { - for (let index = 0; index < edge.points.numberOfItems; index += 1) { - const point = edge.points.getItem(index); - localPoints.push({ x: point.x, y: point.y }); - } - } - - const screenPoints = localPoints.map((point) => - transformPoint(point.x, point.y, matrix) - ); - - return screenPoints.slice(1).map((point, index) => ({ - start: screenPoints[index], - end: point - })); - }; - - const segmentIntersectsRect = ( - start: { x: number; y: number }, - end: { x: number; y: number }, - rect: DOMRect - ) => { - const left = rect.left + 1; - const right = rect.right - 1; - const top = rect.top + 1; - const bottom = rect.bottom - 1; - - const inside = (point: { x: number; y: number }) => - point.x >= left && point.x <= right && point.y >= top && point.y <= bottom; - if (inside(start) || inside(end)) return true; - - const edges = [ - [{ x: left, y: top }, { x: right, y: top }], - [{ x: right, y: top }, { x: right, y: bottom }], - [{ x: right, y: bottom }, { x: left, y: bottom }], - [{ x: left, y: bottom }, { x: left, y: top }] - ] as const; - - const cross = ( - a: { x: number; y: number }, - b: { x: number; y: number }, - c: { x: number; y: number } - ) => (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x); - - return edges.some(([a, b]) => { - const c1 = cross(start, end, a); - const c2 = cross(start, end, b); - const c3 = cross(a, b, start); - const c4 = cross(a, b, end); - return c1 * c2 <= 0 && c3 * c4 <= 0; - }); - }; - - const result: string[] = []; - const labels = [...document.querySelectorAll(".hero-label-group")]; - - for (const edge of edges) { - const segments = screenSegmentsFor(edge); - for (const label of labels) { - const rect = label.getBoundingClientRect(); - if ( - segments.some(({ start, end }) => - segmentIntersectsRect(start, end, rect) - ) - ) { - result.push(`${edge.dataset.sourceHero}->${edge.dataset.targetHero} crosses ${label.dataset.heroLabel}`); - } - } - } + return { + heroId, + selected: node.classList.contains("hero-selected"), + active: node.classList.contains("hero-active"), + separated: gapRight >= 4 || gapLeft >= 4, + verticalDelta + }; + }) + .filter((row): row is NonNullable => row !== null); - return result; + return rows; }); - expect(intersections).toEqual([]); + expect(geometry.length).toBeGreaterThan(0); + for (const row of geometry) { + expect(row.selected || row.active, row.heroId).toBe(true); + expect(row.separated, row.heroId).toBe(true); + expect(row.verticalDelta, row.heroId).toBeLessThan(2); + } }); test("focused win-rate labels stay source-anchored and do not overlap", async ({ page }) => { @@ -431,15 +377,9 @@ test("focused win-rate labels stay source-anchored and do not overlap", async ({ const leader = entry.locator(".edge-label-leader"); expect(Number.isFinite(t)).toBe(true); - if (external === "true") { - await expect(leader).toHaveCount(1); - expect(await leader.boundingBox()).not.toBeNull(); - expect(t < 0 || t > 1).toBe(true); - } else { - await expect(leader).toHaveCount(0); - expect(t).toBeGreaterThanOrEqual(0.12); - expect(t).toBeLessThanOrEqual(0.88); - } + expect(external).toBe("false"); + await expect(leader).toHaveCount(0); + expect([0.28, 0.72]).toContain(Number(t.toFixed(2))); const box = await label.boundingBox(); expect(box).not.toBeNull(); @@ -667,27 +607,13 @@ test("focus win-rate badges stay attached to rendered relationship paths", async expect(geometry.rows.length).toBeGreaterThan(0); for (const row of geometry.rows) { expect(row.offset).toBe(0); - if (row.external) { - expect(row.leaderExists).toBe(true); - expect( - row.t < 0 || row.t > 1, - `${heroId}: ${row.source}->${row.target}` - ).toBe(true); - expect( - row.leaderLineDistance, - `${heroId}: ${row.source}->${row.target} leader left the relationship continuation` - ).toBeLessThan(1.25); - expect( - row.leaderLength, - `${heroId}: ${row.source}->${row.target} leader became too long` - ).toBeLessThanOrEqual(160); - } else { - expect( - row.distanceToRoute, - `${heroId}: ${row.source}->${row.target}` - ).toBeLessThan(1.25); - } - expect(row.overlapsPortrait).toBe(false); + expect(row.external, `${heroId}: ${row.source}->${row.target}`).toBe(false); + expect(row.leaderExists).toBe(false); + expect([0.28, 0.72]).toContain(Number(row.t.toFixed(2))); + expect( + row.distanceToRoute, + `${heroId}: ${row.source}->${row.target}` + ).toBeLessThan(1.25); } } }); @@ -1128,49 +1054,27 @@ test("mouse wheel zooms the graph", async ({ page }) => { .not.toBe(before); }); -test("desktop hero selection only pans the existing overview camera for the card", async ({ page }) => { +test("desktop hero selection keeps the global camera and node positions unchanged", async ({ page }) => { await page.setViewportSize({ width: 1366, height: 768 }); await page.goto("/"); const camera = page.locator(".graph-camera"); - const search = page.getByRole("combobox", { name: "Search for a hero" }); - const before = await camera.getAttribute("transform"); + const selectedNode = page.locator("#graph-hero-underlord"); + const beforeCamera = await camera.getAttribute("transform"); + const beforeNode = await selectedNode.getAttribute("transform"); + const search = page.getByRole("combobox", { name: "Search for a hero" }); await search.fill("underlord"); await page.getByRole("option", { name: /Underlord/ }).click(); - await page.waitForTimeout(520); - - const after = await camera.getAttribute("transform"); - expect(before).not.toBeNull(); - expect(after).not.toBeNull(); + await page.waitForTimeout(120); - const parseCamera = (transform: string) => { - const match = transform.match( - /translate\(([-\d.]+) ([-\d.]+)\) scale\(([-\d.]+)\) translate\(([-\d.]+) ([-\d.]+)\)/ - ); - if (!match) return null; - return match.slice(1).map(Number); - }; - - const beforeParts = parseCamera(before!); - const afterParts = parseCamera(after!); - expect(beforeParts).not.toBeNull(); - expect(afterParts).not.toBeNull(); - - expect(afterParts![0] - beforeParts![0]).toBeCloseTo(180, 1); - expect(afterParts![1]).toBeCloseTo(beforeParts![1], 4); - expect(afterParts![2]).toBeCloseTo(beforeParts![2], 4); - expect(afterParts![3]).toBeCloseTo(beforeParts![3], 4); - expect(afterParts![4]).toBeCloseTo(beforeParts![4], 4); + expect(await camera.getAttribute("transform")).toBe(beforeCamera); + expect(await selectedNode.getAttribute("transform")).toBe(beforeNode); + await expect(selectedNode).toHaveClass(/hero-selected/); await expect(page).toHaveURL(/hero=underlord/); - - const activeHeroes = page.locator(".hero-active"); - const activeCount = await activeHeroes.count(); - expect(activeCount).toBeGreaterThan(0); - expect(activeCount).toBeLessThanOrEqual(10); }); -test("reduced-motion desktop selection applies only the card pan immediately", async ({ page }) => { +test("reduced-motion desktop selection also preserves the global camera", async ({ page }) => { await page.emulateMedia({ reducedMotion: "reduce" }); await page.setViewportSize({ width: 1366, height: 768 }); await page.goto("/"); @@ -1183,10 +1087,7 @@ test("reduced-motion desktop selection applies only the card pan immediately", a await page.keyboard.press("Enter"); await page.waitForTimeout(30); - const afterSelection = await camera.getAttribute("transform"); - await page.waitForTimeout(120); - expect(afterSelection).not.toBe(before); - expect(await camera.getAttribute("transform")).toBe(afterSelection); + expect(await camera.getAttribute("transform")).toBe(before); }); test("site exposes the DotaGraph logo as favicon and header brand", async ({ page }) => { From 0ff337a78dd16e7dc4ebd71879c1d7c47ce823b4 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:33:25 +0300 Subject: [PATCH 013/114] ui: keep selected hero clear of the desktop card --- src/graph/GraphView.tsx | 30 +++++++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index 861a6bf8..3c3af556 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -63,6 +63,7 @@ const MIN_ZOOM = 0.55; const MAX_ZOOM = 2.4; const DRAG_THRESHOLD = 5; const COMPACT_VIEWPORT_QUERY = "(max-width: 640px)"; +const DESKTOP_FOCUS_SAFE_X = 360; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); @@ -135,6 +136,17 @@ export function GraphView({ }) : overviewCamera.scale; + const initialDesktopFocusPanX = + selectedHero && !initialCompactViewport + ? Math.max( + 0, + DESKTOP_FOCUS_SAFE_X - + (WIDTH / 2 + + overviewCamera.scale * + (selectedHero.x - overviewCamera.anchorX)) + ) + : 0; + const initialCamera: CameraState = { anchorX: selectedHero && initialCompactViewport @@ -144,7 +156,7 @@ export function GraphView({ selectedHero && initialCompactViewport ? selectedHero.y : overviewCamera.anchorY, - panX: 0, + panX: initialDesktopFocusPanX, panY: selectedHero && initialCompactViewport ? -92 : 0, zoom: 1, focusScale: @@ -196,6 +208,22 @@ export function GraphView({ return; } + if (selectedHero && !isCompactViewport) { + const projectedX = + WIDTH / 2 + + overviewCamera.scale * (selectedHero.x - overviewCamera.anchorX); + setCamera((current) => ({ + ...current, + anchorX: overviewCamera.anchorX, + anchorY: overviewCamera.anchorY, + panX: Math.max(0, DESKTOP_FOCUS_SAFE_X - projectedX), + panY: 0, + zoom: 1, + focusScale: overviewCamera.scale + })); + return; + } + if (!selectedHeroId) { setCamera((current) => ({ ...current, From 53827c8449651844b81eaded6cbe757db6362d2f Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:33:39 +0300 Subject: [PATCH 014/114] ui: add portrait masks over relationship lines --- src/graph/GraphView.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index 3c3af556..935c1c0d 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -760,6 +760,7 @@ export function GraphView({ }} > + {portraitAsset.status === "ready" ? ( Date: Sat, 26 Sep 2026 16:33:48 +0300 Subject: [PATCH 015/114] ui: hide straight edges beneath hero portraits --- src/styles/global.css | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/styles/global.css b/src/styles/global.css index 4ae88fb2..9b90c396 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -458,6 +458,11 @@ button:focus-visible, input:focus-visible, [role="button"]:focus-visible { .node-hitarea { fill: transparent; } +.hero-node-mask { + fill: var(--canvas); + pointer-events: none; +} + .node-ring { fill: var(--panel); stroke: #6e7681; From a63b4c91698fa0d54a879ebfdf1fc740bb43d477 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:37:55 +0300 Subject: [PATCH 016/114] ui: clear dimmed background nodes from active lanes --- src/graph/GraphView.tsx | 77 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 76 insertions(+), 1 deletion(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index 935c1c0d..f196a542 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -418,6 +418,78 @@ export function GraphView({ }) ); + const distanceToSegment = ( + px: number, + py: number, + x1: number, + y1: number, + x2: number, + y2: number + ) => { + const dx = x2 - x1; + const dy = y2 - y1; + const lengthSquared = dx * dx + dy * dy; + if (!lengthSquared) return Math.hypot(px - x1, py - y1); + + const t = clamp( + ((px - x1) * dx + (py - y1) * dy) / lengthSquared, + 0, + 1 + ); + return Math.hypot( + px - (x1 + dx * t), + py - (y1 + dy * t) + ); + }; + + const obscuredDimmedIds = new Set( + selectedHeroId + ? heroes + .filter((hero) => !activeIds.has(hero.id)) + .filter((hero) => { + for (const relationship of activeRelationships) { + const source = byId.get(relationship.sourceHeroId); + const target = byId.get(relationship.targetHeroId); + if (!source || !target) continue; + + const geometry = edgeGeometry(source, target); + if ( + distanceToSegment( + hero.x, + hero.y, + geometry.x1, + geometry.y1, + geometry.x2, + geometry.y2 + ) < radiusFor(hero.id) + 7 + ) { + return true; + } + + const badge = edgeLabelPlacements.get(relationship.id); + if (!badge) continue; + + const halfWidth = + EDGE_LABEL_WIDTH / (2 * Math.max(cameraScale, 0.001)) + + radiusFor(hero.id) + 4; + const halfHeight = + EDGE_LABEL_HEIGHT / (2 * Math.max(cameraScale, 0.001)) + + radiusFor(hero.id) + 4; + + if ( + Math.abs(hero.x - badge.x) < halfWidth && + Math.abs(hero.y - badge.y) < halfHeight + ) { + return true; + } + } + + return false; + }) + .map((hero) => hero.id) + : [] + ); + const handlePointerDown = (event: ReactPointerEvent) => { if (event.button !== 0) return; const target = event.target; @@ -679,7 +751,10 @@ export function GraphView({ isActive ? "hero-active" : "", isHovered ? "hero-hovered" : "", isMatchup ? "hero-matchup" : "", - shouldDim ? "hero-dimmed" : "" + shouldDim ? "hero-dimmed" : "", + shouldDim && obscuredDimmedIds.has(hero.id) + ? "hero-obscured" + : "" ].filter(Boolean).join(" "); const activeRelationship = activeRelationships.find( From 37c069079fd4af60e9674517ad2afe27bd326ed6 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:38:04 +0300 Subject: [PATCH 017/114] ui: suppress background portraits that block focus links --- src/styles/global.css | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/styles/global.css b/src/styles/global.css index 9b90c396..63b61cc4 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -471,6 +471,10 @@ button:focus-visible, input:focus-visible, [role="button"]:focus-visible { } .hero-dimmed { opacity: .14; } +.hero-dimmed.hero-obscured { + opacity: 0; + pointer-events: none; +} .hero-active { opacity: 1; } .hero-active .node-ring { stroke: #8b949e; } .hero-selected .node-ring { stroke: var(--selected); stroke-width: 4; } From 55c03aef24c7c78833fb45b54dc9ee787e9a07fd Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:38:34 +0300 Subject: [PATCH 018/114] test: capture owner reference heroes on the global layout --- tests/e2e/screenshots.spec.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/e2e/screenshots.spec.ts b/tests/e2e/screenshots.spec.ts index 6b2c0589..378de59e 100644 --- a/tests/e2e/screenshots.spec.ts +++ b/tests/e2e/screenshots.spec.ts @@ -181,6 +181,24 @@ test("capture clean directional Focus regression cases", async ({ page }) => { } }); +test("capture owner reference Focus cases on the stable global layout", async ({ page }) => { + await page.setViewportSize({ width: 1366, height: 768 }); + + for (const heroId of [ + "earthshaker", + "enchantress", + "ember-spirit", + "crystal-maiden" + ]) { + await page.goto(`/?hero=${heroId}`, { waitUntil: "networkidle" }); + await page.waitForTimeout(520); + await page.screenshot({ + path: `${output}/focus-${heroId}-organic-reference.png`, + fullPage: true + }); + } +}); + test("capture mobile Focus and Matchup states", async ({ page }) => { await page.setViewportSize({ width: 390, height: 844 }); await page.goto("/?hero=viper", { waitUntil: "networkidle" }); From 57eafb968b7caa3f3eb39cd3b7758e48fa6243a7 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:39:20 +0300 Subject: [PATCH 019/114] test: audit visual edge occlusion instead of hidden geometry --- tests/e2e/visual-collisions.spec.ts | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/tests/e2e/visual-collisions.spec.ts b/tests/e2e/visual-collisions.spec.ts index a36e277f..1437736b 100644 --- a/tests/e2e/visual-collisions.spec.ts +++ b/tests/e2e/visual-collisions.spec.ts @@ -63,14 +63,17 @@ const scanVisualCollisions = async (page: import("@playwright/test").Page) => ) ).flatMap((node) => { const portrait = node.querySelector(".hero-portrait-node"); - if (!portrait) return []; + const mask = node.querySelector(".hero-node-mask"); + if (!portrait || !mask) return []; const rect = portrait.getBoundingClientRect(); + const maskRect = mask.getBoundingClientRect(); return [{ hero: node.id.replace("graph-hero-", ""), x: rect.left + rect.width / 2, y: rect.top + rect.height / 2, - radius: Math.min(rect.width, rect.height) / 2 + radius: Math.min(rect.width, rect.height) / 2, + maskRadius: Math.min(maskRect.width, maskRect.height) / 2 }]; }); @@ -127,6 +130,11 @@ const scanVisualCollisions = async (page: import("@playwright/test").Page) => ); if (!crosses) continue; + // Active edges render below the node layer. A sufficiently large + // opaque mask means the relationship is visually interrupted before + // it reaches an unrelated portrait instead of painting over the art. + if (node.maskRadius >= node.radius + 3) continue; + activeEdgeForeignPortrait.push( `${source}->${target} crosses portrait:${node.hero}` ); @@ -135,7 +143,9 @@ const scanVisualCollisions = async (page: import("@playwright/test").Page) => const badgeDimmedPortrait: string[] = []; const dimmedNodes = Array.from( - document.querySelectorAll(".hero-node.hero-dimmed") + document.querySelectorAll( + ".hero-node.hero-dimmed:not(.hero-obscured)" + ) ); for (const badge of badges) { From d562ebed8c9c4b691756c8f6b00fc12c9d953758 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:45:12 +0300 Subject: [PATCH 020/114] ui: anchor hero names on each node's stable open side --- src/graph/GraphView.tsx | 66 ++++++++++++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index f196a542..152dae06 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -68,6 +68,44 @@ const DESKTOP_FOCUS_SAFE_X = 360; const clamp = (value: number, min: number, max: number) => Math.min(max, Math.max(min, value)); +const normalizeAngle = (angle: number) => { + const fullTurn = Math.PI * 2; + const normalized = angle % fullTurn; + return normalized < 0 ? normalized + fullTurn : normalized; +}; + +const largestAngularGap = ( + center: { x: number; y: number }, + neighbors: Array<{ x: number; y: number }> +) => { + if (!neighbors.length) return 0; + + const angles = neighbors + .map((neighbor) => + normalizeAngle(Math.atan2(neighbor.y - center.y, neighbor.x - center.x)) + ) + .sort((left, right) => left - right); + + let bestStart = angles[0]; + let bestGap = -1; + + for (let index = 0; index < angles.length; index += 1) { + const start = angles[index]; + const end = + index === angles.length - 1 + ? angles[0] + Math.PI * 2 + : angles[index + 1]; + const gap = end - start; + + if (gap > bestGap) { + bestGap = gap; + bestStart = start; + } + } + + return normalizeAngle(bestStart + bestGap / 2); +}; + export function GraphView({ heroes, relationships, @@ -371,11 +409,37 @@ export function GraphView({ hero.id === hoveredHeroId ); + const layoutMinX = Math.min(...heroes.map((hero) => hero.x), 0); const layoutMaxX = Math.max(...heroes.map((hero) => hero.x), WIDTH); + const stableLabelNeighbors = new Map(); + + for (const relationship of overviewRelationships) { + const source = byId.get(relationship.sourceHeroId); + const target = byId.get(relationship.targetHeroId); + if (!source || !target) continue; + + stableLabelNeighbors.set(source.id, [ + ...(stableLabelNeighbors.get(source.id) ?? []), + target + ]); + stableLabelNeighbors.set(target.id, [ + ...(stableLabelNeighbors.get(target.id) ?? []), + source + ]); + } + const fixedLabelPlacements = new Map( labelHeroes.map((hero) => { const width = labelWidthFor(hero); - const placeLeft = hero.x > layoutMaxX - 210; + const freeAngle = largestAngularGap( + hero, + stableLabelNeighbors.get(hero.id) ?? [] + ); + let placeLeft = Math.cos(freeAngle) < 0; + + if (hero.x < layoutMinX + 170) placeLeft = false; + if (hero.x > layoutMaxX - 170) placeLeft = true; + const direction = placeLeft ? -1 : 1; const screenGap = hero.id === selectedHeroId ? 12 : 9; const centerOffset = From 223cb5b385902db5c7c06709d6a4f8035771fc4b Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:45:33 +0300 Subject: [PATCH 021/114] ui: keep compact win-rate pills on-screen and on-line --- src/graph/GraphView.tsx | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/graph/GraphView.tsx b/src/graph/GraphView.tsx index 152dae06..7d0925e4 100644 --- a/src/graph/GraphView.tsx +++ b/src/graph/GraphView.tsx @@ -463,11 +463,48 @@ export function GraphView({ const target = byId.get(relationship.targetHeroId); if (!source || !target) return []; - const t = + const preferredT = relationship.sourceHeroId === selectedHeroId ? 0.72 : 0.28; const geometry = edgeGeometry(source, target); + let t = preferredT; + + if (isCompactViewport) { + const visibleLeft = (WIDTH - visibleGraphSpan.width) / 2; + const visibleRight = WIDTH - visibleLeft; + const visibleTop = (HEIGHT - visibleGraphSpan.height) / 2; + const visibleBottom = HEIGHT - visibleTop; + const halfWidth = EDGE_LABEL_WIDTH * 0.8 / 2 + 4; + const halfHeight = EDGE_LABEL_HEIGHT * 0.8 / 2 + 4; + const candidates = [ + preferredT, + 0.5, + 0.42, + 0.58, + 0.34, + 0.66, + 0.25, + 0.75 + ]; + + const safeCandidate = candidates.find((candidate) => { + const graphX = + geometry.x1 + (geometry.x2 - geometry.x1) * candidate; + const graphY = + geometry.y1 + (geometry.y2 - geometry.y1) * candidate; + const point = projectGraphPoint(graphX, graphY); + + return ( + point.x - halfWidth >= visibleLeft && + point.x + halfWidth <= visibleRight && + point.y - halfHeight >= visibleTop && + point.y + halfHeight <= visibleBottom + ); + }); + + if (safeCandidate !== undefined) t = safeCandidate; + } return [[ relationship.id, From 003f12a67d42a281e4f4144dc02a0d0606824567 Mon Sep 17 00:00:00 2001 From: Chychyndr <171921961+Chychyndr@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:49:51 +0300 Subject: [PATCH 022/114] ui: place hero card opposite the selected node --- src/components/HeroCard.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/components/HeroCard.tsx b/src/components/HeroCard.tsx index d51097c2..43ad6e6f 100644 --- a/src/components/HeroCard.tsx +++ b/src/components/HeroCard.tsx @@ -57,8 +57,19 @@ function RelationRows({ } export function HeroCard({ hero, incoming, outgoing, onMatchup, heroesById }: HeroCardProps) { + const heroXs = [...heroesById.values()].map((item) => item.x); + const graphCenterX = + heroXs.length > 0 + ? (Math.min(...heroXs) + Math.max(...heroXs)) / 2 + : hero.x; + const cardSideClass = + hero.x < graphCenterX ? "context-card-right" : "context-card-left"; + return ( -