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");