Improve map editing interactions
This commit is contained in:
+111
-18
@@ -175,6 +175,7 @@
|
|||||||
stroke-width: 1.5;
|
stroke-width: 1.5;
|
||||||
opacity: 0.7;
|
opacity: 0.7;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.connection.trade {
|
.connection.trade {
|
||||||
@@ -189,7 +190,7 @@
|
|||||||
|
|
||||||
.connection-hit {
|
.connection-hit {
|
||||||
stroke: transparent;
|
stroke: transparent;
|
||||||
stroke-width: 14;
|
stroke-width: 28;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,8 +240,9 @@
|
|||||||
font-size: 19px;
|
font-size: 19px;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
text-anchor: middle;
|
text-anchor: middle;
|
||||||
pointer-events: none;
|
pointer-events: auto;
|
||||||
user-select: none;
|
user-select: none;
|
||||||
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar {
|
.sidebar {
|
||||||
@@ -379,6 +381,11 @@
|
|||||||
<button class="action-button danger" id="deleteSelected" type="button">Loeschen</button>
|
<button class="action-button danger" id="deleteSelected" type="button">Loeschen</button>
|
||||||
<button class="action-button" id="resetMap" type="button">Vorlage laden</button>
|
<button class="action-button" id="resetMap" type="button">Vorlage laden</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="tool-group" role="group" aria-label="Zoom">
|
||||||
|
<button class="action-button" id="zoomOut" type="button" title="Rauszoomen">-</button>
|
||||||
|
<button class="action-button" id="resetZoom" type="button" title="Zoom zuruecksetzen">1:1</button>
|
||||||
|
<button class="action-button" id="zoomIn" type="button" title="Ranzoomen">+</button>
|
||||||
|
</div>
|
||||||
<div class="tool-group">
|
<div class="tool-group">
|
||||||
<label class="background-label" for="backgroundColor">Hintergrund <input id="backgroundColor" type="color" value="#05080f"></label>
|
<label class="background-label" for="backgroundColor">Hintergrund <input id="backgroundColor" type="color" value="#05080f"></label>
|
||||||
<span class="background-label">Raster aktiv</span>
|
<span class="background-label">Raster aktiv</span>
|
||||||
@@ -397,8 +404,8 @@
|
|||||||
</defs>
|
</defs>
|
||||||
<g id="gridLayer"></g>
|
<g id="gridLayer"></g>
|
||||||
<rect x="55" y="59" width="984" height="984" class="sector-border"></rect>
|
<rect x="55" y="59" width="984" height="984" class="sector-border"></rect>
|
||||||
<g id="connectionHits"></g>
|
|
||||||
<g id="connections"></g>
|
<g id="connections"></g>
|
||||||
|
<g id="connectionHits"></g>
|
||||||
<g id="systems"></g>
|
<g id="systems"></g>
|
||||||
</svg>
|
</svg>
|
||||||
</main>
|
</main>
|
||||||
@@ -412,6 +419,10 @@
|
|||||||
<label for="systemName">Systemname</label>
|
<label for="systemName">Systemname</label>
|
||||||
<input class="text-input" id="systemName" type="text" autocomplete="off">
|
<input class="text-input" id="systemName" type="text" autocomplete="off">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label for="systemColor">Farbe</label>
|
||||||
|
<input id="systemColor" type="color" value="#d9f0ff">
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="systemNote">Notiz</label>
|
<label for="systemNote">Notiz</label>
|
||||||
<textarea id="systemNote" placeholder="Notiz fuer dieses System"></textarea>
|
<textarea id="systemNote" placeholder="Notiz fuer dieses System"></textarea>
|
||||||
@@ -1904,10 +1915,14 @@
|
|||||||
const systemEditor = document.getElementById("systemEditor");
|
const systemEditor = document.getElementById("systemEditor");
|
||||||
const connectionEditor = document.getElementById("connectionEditor");
|
const connectionEditor = document.getElementById("connectionEditor");
|
||||||
const systemName = document.getElementById("systemName");
|
const systemName = document.getElementById("systemName");
|
||||||
|
const systemColor = document.getElementById("systemColor");
|
||||||
const systemNote = document.getElementById("systemNote");
|
const systemNote = document.getElementById("systemNote");
|
||||||
const connectionType = document.getElementById("connectionType");
|
const connectionType = document.getElementById("connectionType");
|
||||||
const deleteSelected = document.getElementById("deleteSelected");
|
const deleteSelected = document.getElementById("deleteSelected");
|
||||||
const backgroundColor = document.getElementById("backgroundColor");
|
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 exportJson = document.getElementById("exportJson");
|
||||||
const importJson = document.getElementById("importJson");
|
const importJson = document.getElementById("importJson");
|
||||||
const saveRemote = document.getElementById("saveRemote");
|
const saveRemote = document.getElementById("saveRemote");
|
||||||
@@ -1924,6 +1939,7 @@
|
|||||||
let pendingConnectionFrom = null;
|
let pendingConnectionFrom = null;
|
||||||
let drag = null;
|
let drag = null;
|
||||||
let suppressClick = false;
|
let suppressClick = false;
|
||||||
|
let currentViewBox = null;
|
||||||
|
|
||||||
function clone(value) {
|
function clone(value) {
|
||||||
return JSON.parse(JSON.stringify(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) {
|
function gridToPoint(gridX, gridY) {
|
||||||
const grid = state.grid;
|
const grid = state.grid;
|
||||||
if (!grid) {
|
if (!grid) {
|
||||||
@@ -2073,6 +2130,7 @@
|
|||||||
y: point.y,
|
y: point.y,
|
||||||
gridX: point.gridX,
|
gridX: point.gridX,
|
||||||
gridY: point.gridY,
|
gridY: point.gridY,
|
||||||
|
color: "#d9f0ff",
|
||||||
note: ""
|
note: ""
|
||||||
};
|
};
|
||||||
state.systems.push(system);
|
state.systems.push(system);
|
||||||
@@ -2276,6 +2334,11 @@
|
|||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stopLabelEvent(event) {
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
function renderSystems() {
|
function renderSystems() {
|
||||||
systemLayer.textContent = "";
|
systemLayer.textContent = "";
|
||||||
|
|
||||||
@@ -2294,20 +2357,18 @@
|
|||||||
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
const circle = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
circle.setAttribute("r", "8");
|
circle.setAttribute("r", "8");
|
||||||
circle.setAttribute("class", "system-core");
|
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");
|
const label = document.createElementNS("http://www.w3.org/2000/svg", "text");
|
||||||
label.setAttribute("class", "system-label");
|
label.setAttribute("class", "system-label");
|
||||||
label.setAttribute("y", "-22");
|
label.setAttribute("y", "-22");
|
||||||
label.textContent = system.name;
|
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 hit = document.createElementNS("http://www.w3.org/2000/svg", "circle");
|
||||||
const hitWidth = Math.max(64, system.name.length * 11 + 24);
|
hit.setAttribute("r", "24");
|
||||||
hit.setAttribute("x", -hitWidth / 2);
|
|
||||||
hit.setAttribute("y", "-50");
|
|
||||||
hit.setAttribute("width", hitWidth);
|
|
||||||
hit.setAttribute("height", "68");
|
|
||||||
hit.setAttribute("rx", "8");
|
|
||||||
hit.setAttribute("class", "system-hit");
|
hit.setAttribute("class", "system-hit");
|
||||||
|
|
||||||
group.append(hit, circle, label);
|
group.append(hit, circle, label);
|
||||||
@@ -2327,8 +2388,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function render() {
|
function render() {
|
||||||
const bounds = mapBounds();
|
if (!currentViewBox) {
|
||||||
svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`);
|
currentViewBox = mapBounds();
|
||||||
|
}
|
||||||
|
svg.setAttribute("viewBox", `${currentViewBox.x} ${currentViewBox.y} ${currentViewBox.width} ${currentViewBox.height}`);
|
||||||
document.documentElement.style.setProperty("--map-bg", state.background || "#05080f");
|
document.documentElement.style.setProperty("--map-bg", state.background || "#05080f");
|
||||||
backgroundColor.value = state.background || "#05080f";
|
backgroundColor.value = state.background || "#05080f";
|
||||||
renderGrid();
|
renderGrid();
|
||||||
@@ -2406,6 +2469,7 @@
|
|||||||
: ` ${system.x}, ${system.y}`;
|
: ` ${system.x}, ${system.y}`;
|
||||||
selectionMeta.textContent = `System: ${system.name} (${gridInfo})`;
|
selectionMeta.textContent = `System: ${system.name} (${gridInfo})`;
|
||||||
systemName.value = system.name;
|
systemName.value = system.name;
|
||||||
|
systemColor.value = system.color || "#d9f0ff";
|
||||||
systemNote.value = system.note || "";
|
systemNote.value = system.note || "";
|
||||||
systemEditor.classList.remove("hidden");
|
systemEditor.classList.remove("hidden");
|
||||||
}
|
}
|
||||||
@@ -2436,6 +2500,7 @@
|
|||||||
state = validateMapData(JSON.parse(dataBox.value));
|
state = validateMapData(JSON.parse(dataBox.value));
|
||||||
selected = null;
|
selected = null;
|
||||||
pendingConnectionFrom = null;
|
pendingConnectionFrom = null;
|
||||||
|
currentViewBox = mapBounds();
|
||||||
saveState();
|
saveState();
|
||||||
render();
|
render();
|
||||||
updateInspector();
|
updateInspector();
|
||||||
@@ -2481,6 +2546,7 @@
|
|||||||
state = validateMapData(await response.json());
|
state = validateMapData(await response.json());
|
||||||
selected = null;
|
selected = null;
|
||||||
pendingConnectionFrom = null;
|
pendingConnectionFrom = null;
|
||||||
|
currentViewBox = mapBounds();
|
||||||
saveState();
|
saveState();
|
||||||
render();
|
render();
|
||||||
updateInspector();
|
updateInspector();
|
||||||
@@ -2505,7 +2571,12 @@
|
|||||||
}
|
}
|
||||||
const width = 420;
|
const width = 420;
|
||||||
const height = 320;
|
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.`);
|
setStatus(`${system.name} zentriert. Doppelklick auf Hintergrund setzt Ansicht zurueck.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2526,8 +2597,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
svg.addEventListener("dblclick", () => {
|
svg.addEventListener("dblclick", () => {
|
||||||
const bounds = mapBounds();
|
resetMapViewBox();
|
||||||
svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`);
|
|
||||||
setStatus("Ansicht zurueckgesetzt.");
|
setStatus("Ansicht zurueckgesetzt.");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -2545,6 +2615,19 @@
|
|||||||
updateInspector();
|
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", () => {
|
systemNote.addEventListener("input", () => {
|
||||||
if (selected?.kind !== "system") {
|
if (selected?.kind !== "system") {
|
||||||
return;
|
return;
|
||||||
@@ -2577,6 +2660,13 @@
|
|||||||
render();
|
render();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
zoomIn.addEventListener("click", () => zoomMap(0.78));
|
||||||
|
zoomOut.addEventListener("click", () => zoomMap(1.28));
|
||||||
|
resetZoom.addEventListener("click", () => {
|
||||||
|
resetMapViewBox();
|
||||||
|
setStatus("Zoom zurueckgesetzt.");
|
||||||
|
});
|
||||||
|
|
||||||
deleteSelected.addEventListener("click", deleteSelection);
|
deleteSelected.addEventListener("click", deleteSelection);
|
||||||
exportJson.addEventListener("click", exportMap);
|
exportJson.addEventListener("click", exportMap);
|
||||||
importJson.addEventListener("click", importMap);
|
importJson.addEventListener("click", importMap);
|
||||||
@@ -2587,8 +2677,7 @@
|
|||||||
state = clone(initialData);
|
state = clone(initialData);
|
||||||
selected = null;
|
selected = null;
|
||||||
pendingConnectionFrom = null;
|
pendingConnectionFrom = null;
|
||||||
const bounds = mapBounds();
|
currentViewBox = mapBounds();
|
||||||
svg.setAttribute("viewBox", `${bounds.x} ${bounds.y} ${bounds.width} ${bounds.height}`);
|
|
||||||
saveState();
|
saveState();
|
||||||
render();
|
render();
|
||||||
updateInspector();
|
updateInspector();
|
||||||
@@ -2611,6 +2700,10 @@
|
|||||||
window.addEventListener("pointermove", handleDragMove);
|
window.addEventListener("pointermove", handleDragMove);
|
||||||
window.addEventListener("pointerup", handleDragEnd);
|
window.addEventListener("pointerup", handleDragEnd);
|
||||||
window.addEventListener("pointercancel", handleDragEnd);
|
window.addEventListener("pointercancel", handleDragEnd);
|
||||||
|
svg.addEventListener("wheel", (event) => {
|
||||||
|
event.preventDefault();
|
||||||
|
zoomMap(event.deltaY < 0 ? 0.84 : 1.18, svgPointFromEvent(event));
|
||||||
|
}, { passive: false });
|
||||||
|
|
||||||
render();
|
render();
|
||||||
updateInspector();
|
updateInspector();
|
||||||
|
|||||||
+16
-1
@@ -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="deleteSelected"/, "missing delete action");
|
||||||
assert.match(html, /id="systemNote"/, "missing per-system note field");
|
assert.match(html, /id="systemNote"/, "missing per-system note field");
|
||||||
assert.match(html, /id="backgroundColor"/, "missing configurable background");
|
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="saveRemote"/, "missing persistent save action");
|
||||||
assert.match(html, /id="loadRemote"/, "missing persistent load action");
|
assert.match(html, /id="loadRemote"/, "missing persistent load action");
|
||||||
assert.doesNotMatch(html, /id="snapToGrid"/, "grid snapping must not be optional");
|
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", () => {
|
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, /function handleDragMove/, "missing shared drag move handler");
|
||||||
assert.match(html, /window\.addEventListener\("pointermove", handleDragMove\)/, "missing window pointermove handler");
|
assert.match(html, /window\.addEventListener\("pointermove", handleDragMove\)/, "missing window pointermove handler");
|
||||||
assert.match(html, /window\.addEventListener\("pointerup", handleDragEnd\)/, "missing window pointerup 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 id="connections"><\/g>\s*<g id="connectionHits"><\/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", () => {
|
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, /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");
|
assert.match(startScript, /127\.0\.0\.1:3000/i, "start script must open the local app URL");
|
||||||
|
|||||||
Reference in New Issue
Block a user