From d2e5bd72aeaea521b5d594323fe862cd8eb0c116 Mon Sep 17 00:00:00 2001 From: FlatHack Date: Thu, 18 Jun 2026 11:46:16 +0200 Subject: [PATCH] Improve map editing interactions --- star-map.html | 129 +++++++++++++++++++++++++++++++++++++++------- star-map.test.mjs | 17 +++++- 2 files changed, 127 insertions(+), 19 deletions(-) diff --git a/star-map.html b/star-map.html index 3ce92b6..e454458 100644 --- a/star-map.html +++ b/star-map.html @@ -175,6 +175,7 @@ stroke-width: 1.5; opacity: 0.7; cursor: pointer; + pointer-events: none; } .connection.trade { @@ -189,7 +190,7 @@ .connection-hit { stroke: transparent; - stroke-width: 14; + stroke-width: 28; cursor: pointer; } @@ -239,8 +240,9 @@ font-size: 19px; font-weight: 800; text-anchor: middle; - pointer-events: none; + pointer-events: auto; user-select: none; + cursor: default; } .sidebar { @@ -379,6 +381,11 @@ +
+ + + +
Raster aktiv @@ -397,8 +404,8 @@ - + @@ -412,6 +419,10 @@
+
+ + +
@@ -1904,10 +1915,14 @@ const systemEditor = document.getElementById("systemEditor"); const connectionEditor = document.getElementById("connectionEditor"); const systemName = document.getElementById("systemName"); + const systemColor = document.getElementById("systemColor"); const systemNote = document.getElementById("systemNote"); const connectionType = document.getElementById("connectionType"); const deleteSelected = document.getElementById("deleteSelected"); const backgroundColor = document.getElementById("backgroundColor"); + const zoomIn = document.getElementById("zoomIn"); + const zoomOut = document.getElementById("zoomOut"); + const resetZoom = document.getElementById("resetZoom"); const exportJson = document.getElementById("exportJson"); const importJson = document.getElementById("importJson"); const saveRemote = document.getElementById("saveRemote"); @@ -1924,6 +1939,7 @@ let pendingConnectionFrom = null; let drag = null; let suppressClick = false; + let currentViewBox = null; function clone(value) { return JSON.parse(JSON.stringify(value)); @@ -1973,6 +1989,47 @@ }; } + function clampViewBox(viewBox) { + const bounds = mapBounds(); + const width = Math.max(160, Math.min(bounds.width, viewBox.width)); + const height = Math.max(160, Math.min(bounds.height, viewBox.height)); + return { + x: Math.max(bounds.x, Math.min(bounds.x + bounds.width - width, viewBox.x)), + y: Math.max(bounds.y, Math.min(bounds.y + bounds.height - height, viewBox.y)), + width, + height + }; + } + + function setMapViewBox(viewBox) { + currentViewBox = clampViewBox(viewBox); + svg.setAttribute("viewBox", `${currentViewBox.x} ${currentViewBox.y} ${currentViewBox.width} ${currentViewBox.height}`); + } + + function resetMapViewBox() { + setMapViewBox(mapBounds()); + } + + function zoomMap(factor, centerPoint) { + if (!currentViewBox) { + resetMapViewBox(); + } + const center = centerPoint || { + x: currentViewBox.x + currentViewBox.width / 2, + y: currentViewBox.y + currentViewBox.height / 2 + }; + const nextWidth = currentViewBox.width * factor; + const nextHeight = currentViewBox.height * factor; + const scaleX = nextWidth / currentViewBox.width; + const scaleY = nextHeight / currentViewBox.height; + setMapViewBox({ + x: center.x - (center.x - currentViewBox.x) * scaleX, + y: center.y - (center.y - currentViewBox.y) * scaleY, + width: nextWidth, + height: nextHeight + }); + } + function gridToPoint(gridX, gridY) { const grid = state.grid; if (!grid) { @@ -2073,6 +2130,7 @@ y: point.y, gridX: point.gridX, gridY: point.gridY, + color: "#d9f0ff", note: "" }; state.systems.push(system); @@ -2276,6 +2334,11 @@ }, 0); } + function stopLabelEvent(event) { + event.stopPropagation(); + event.preventDefault(); + } + function renderSystems() { systemLayer.textContent = ""; @@ -2294,20 +2357,18 @@ const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle"); circle.setAttribute("r", "8"); circle.setAttribute("class", "system-core"); - circle.setAttribute("fill", "url(#nodeGlow)"); + circle.style.fill = system.color || "#d9f0ff"; const label = document.createElementNS("http://www.w3.org/2000/svg", "text"); label.setAttribute("class", "system-label"); label.setAttribute("y", "-22"); label.textContent = system.name; + label.addEventListener("pointerdown", stopLabelEvent); + label.addEventListener("click", stopLabelEvent); + label.addEventListener("dblclick", stopLabelEvent); - const hit = document.createElementNS("http://www.w3.org/2000/svg", "rect"); - const hitWidth = Math.max(64, system.name.length * 11 + 24); - hit.setAttribute("x", -hitWidth / 2); - hit.setAttribute("y", "-50"); - hit.setAttribute("width", hitWidth); - hit.setAttribute("height", "68"); - hit.setAttribute("rx", "8"); + const hit = document.createElementNS("http://www.w3.org/2000/svg", "circle"); + hit.setAttribute("r", "24"); hit.setAttribute("class", "system-hit"); group.append(hit, circle, label); @@ -2327,8 +2388,10 @@ } function render() { - const bounds = mapBounds(); - svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`); + if (!currentViewBox) { + currentViewBox = mapBounds(); + } + svg.setAttribute("viewBox", `${currentViewBox.x} ${currentViewBox.y} ${currentViewBox.width} ${currentViewBox.height}`); document.documentElement.style.setProperty("--map-bg", state.background || "#05080f"); backgroundColor.value = state.background || "#05080f"; renderGrid(); @@ -2406,6 +2469,7 @@ : ` ${system.x}, ${system.y}`; selectionMeta.textContent = `System: ${system.name} (${gridInfo})`; systemName.value = system.name; + systemColor.value = system.color || "#d9f0ff"; systemNote.value = system.note || ""; systemEditor.classList.remove("hidden"); } @@ -2436,6 +2500,7 @@ state = validateMapData(JSON.parse(dataBox.value)); selected = null; pendingConnectionFrom = null; + currentViewBox = mapBounds(); saveState(); render(); updateInspector(); @@ -2481,6 +2546,7 @@ state = validateMapData(await response.json()); selected = null; pendingConnectionFrom = null; + currentViewBox = mapBounds(); saveState(); render(); updateInspector(); @@ -2505,7 +2571,12 @@ } const width = 420; const height = 320; - svg.setAttribute("viewBox", `${Math.max(0, system.x - width / 2)} ${Math.max(0, system.y - height / 2)} ${width} ${height}`); + setMapViewBox({ + x: system.x - width / 2, + y: system.y - height / 2, + width, + height + }); setStatus(`${system.name} zentriert. Doppelklick auf Hintergrund setzt Ansicht zurueck.`); } @@ -2526,8 +2597,7 @@ }); svg.addEventListener("dblclick", () => { - const bounds = mapBounds(); - svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`); + resetMapViewBox(); setStatus("Ansicht zurueckgesetzt."); }); @@ -2545,6 +2615,19 @@ updateInspector(); }); + systemColor.addEventListener("input", () => { + if (selected?.kind !== "system") { + return; + } + const system = findSystem(selected.id); + if (!system) { + return; + } + system.color = systemColor.value; + saveState(); + render(); + }); + systemNote.addEventListener("input", () => { if (selected?.kind !== "system") { return; @@ -2577,6 +2660,13 @@ render(); }); + zoomIn.addEventListener("click", () => zoomMap(0.78)); + zoomOut.addEventListener("click", () => zoomMap(1.28)); + resetZoom.addEventListener("click", () => { + resetMapViewBox(); + setStatus("Zoom zurueckgesetzt."); + }); + deleteSelected.addEventListener("click", deleteSelection); exportJson.addEventListener("click", exportMap); importJson.addEventListener("click", importMap); @@ -2587,8 +2677,7 @@ state = clone(initialData); selected = null; pendingConnectionFrom = null; - const bounds = mapBounds(); - svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`); + currentViewBox = mapBounds(); saveState(); render(); updateInspector(); @@ -2611,6 +2700,10 @@ window.addEventListener("pointermove", handleDragMove); window.addEventListener("pointerup", handleDragEnd); window.addEventListener("pointercancel", handleDragEnd); + svg.addEventListener("wheel", (event) => { + event.preventDefault(); + zoomMap(event.deltaY < 0 ? 0.84 : 1.18, svgPointFromEvent(event)); + }, { passive: false }); render(); updateInspector(); diff --git a/star-map.test.mjs b/star-map.test.mjs index a8e3774..d701b70 100644 --- a/star-map.test.mjs +++ b/star-map.test.mjs @@ -65,6 +65,7 @@ test("editor UI exposes creation, deletion, linking, notes, and background contr assert.match(html, /id="deleteSelected"/, "missing delete action"); assert.match(html, /id="systemNote"/, "missing per-system note field"); assert.match(html, /id="backgroundColor"/, "missing configurable background"); + assert.match(html, /id="systemColor"/, "missing per-system color control"); assert.match(html, /id="saveRemote"/, "missing persistent save action"); assert.match(html, /id="loadRemote"/, "missing persistent load action"); assert.doesNotMatch(html, /id="snapToGrid"/, "grid snapping must not be optional"); @@ -77,12 +78,26 @@ test("editor UI exposes creation, deletion, linking, notes, and background contr }); test("system dragging has a forgiving hit target and stable pointer handlers", () => { - assert.match(html, /class", "system-hit"/, "missing large system drag hit target"); + assert.match(html, /createElementNS\("http:\/\/www\.w3\.org\/2000\/svg", "circle"\)[\s\S]*?class", "system-hit"/, "missing circular system drag hit target"); + assert.doesNotMatch(html, /createElementNS\("http:\/\/www\.w3\.org\/2000\/svg", "rect"\)[\s\S]*?class", "system-hit"/, "system labels must not be covered by click hit rects"); + assert.match(html, /function stopLabelEvent/, "missing label event blocker"); + assert.match(html, /label\.addEventListener\("click", stopLabelEvent\)/, "labels should swallow clicks without selecting objects"); assert.match(html, /function handleDragMove/, "missing shared drag move handler"); assert.match(html, /window\.addEventListener\("pointermove", handleDragMove\)/, "missing window pointermove handler"); assert.match(html, /window\.addEventListener\("pointerup", handleDragEnd\)/, "missing window pointerup handler"); }); +test("map supports zoom and easier connection selection", () => { + assert.match(html, /id="zoomIn"/, "missing zoom-in action"); + assert.match(html, /id="zoomOut"/, "missing zoom-out action"); + assert.match(html, /id="resetZoom"/, "missing reset zoom action"); + assert.match(html, /function zoomMap/, "missing zoom behavior"); + assert.match(html, /svg\.addEventListener\("wheel"/, "missing wheel zoom"); + assert.match(html, /<\/g>\s*<\/g>/, "connection hit layer should sit above visible lines"); + assert.match(html, /\.connection\s*\{[\s\S]*?pointer-events:\s*none;/, "visible connections should not block hit lines"); + assert.match(html, /\.connection-hit\s*\{[\s\S]*?stroke-width:\s*28;/, "connection hit target should be wide"); +}); + test("start script launches the local server and opens the browser", () => { assert.match(startScript, /node.*server\.mjs/i, "start script must launch server.mjs with node"); assert.match(startScript, /127\.0\.0\.1:3000/i, "start script must open the local app URL");