math-tasks/tasks/space-route/editor.html

553 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Route Editor</title>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body { background: #1e1e2e; color: #cdd6f4; font-family: 'Segoe UI', system-ui, sans-serif; }
#toolbar {
position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
background: #181825; border-bottom: 1px solid #313244;
display: flex; align-items: center; gap: 8px; padding: 8px 12px; height: 48px;
flex-wrap: nowrap; overflow-x: auto;
}
#toolbar button {
background: #313244; color: #cdd6f4; border: 1px solid #45475a;
padding: 5px 10px; border-radius: 6px; cursor: pointer; font-size: 12px;
transition: background 0.15s; white-space: nowrap;
}
#toolbar button:hover { background: #45475a; }
#toolbar button.primary { background: #6366f1; border-color: #818cf8; color: white; }
#toolbar button.primary:hover { background: #818cf8; }
#toolbar button.active { background: #f59e0b; border-color: #fbbf24; color: #1e1e2e; }
#toolbar .page-nav { display: flex; align-items: center; gap: 4px; }
#toolbar .page-nav span { font-size: 13px; min-width: 80px; text-align: center; }
#toolbar .spacer { flex: 1; }
#toolbar .sep { width: 1px; height: 24px; background: #45475a; }
#toolbar .title { font-weight: 600; font-size: 13px; color: #a6adc8; }
#statusbar {
position: fixed; bottom: 0; left: 0; right: 0; z-index: 1000;
background: #181825; border-top: 1px solid #313244;
padding: 6px 16px; font-size: 12px; height: 32px;
display: flex; align-items: center; gap: 16px;
}
#statusbar .pos { color: #89b4fa; }
#statusbar .info { color: #a6adc8; }
#statusbar .keys { color: #6c7086; font-size: 11px; }
#worksheet-container {
padding: 56px 0 32px;
display: flex; flex-direction: column; align-items: center; gap: 20px;
}
.obj-draggable { cursor: grab; user-select: none; z-index: 20 !important; position: absolute; }
body.node-mode .obj-draggable { pointer-events: none !important; z-index: 4 !important; }
body.node-mode .node-draggable { z-index: 20 !important; cursor: grab; }
.obj-draggable:hover { outline: 2px solid rgba(99, 102, 241, 0.6); outline-offset: 2px; }
.obj-selected { outline: 3px solid #6366f1 !important; outline-offset: 2px; }
.obj-changed::after {
content: ''; position: absolute; top: -2px; right: -2px;
width: 8px; height: 8px; background: #f59e0b; border-radius: 50%;
border: 1px solid #1e1e2e; z-index: 100;
}
.node-draggable { cursor: grab; user-select: none; }
.node-draggable:hover { box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.4) !important; }
.node-selected { box-shadow: 0 0 0 3px #f59e0b !important; }
.node-changed { box-shadow: 0 0 0 2px #f59e0b, 0 0 8px rgba(251, 191, 36, 0.3) !important; }
body.dragging, body.dragging * { cursor: grabbing !important; }
#coord-tooltip {
position: fixed; z-index: 2000; pointer-events: none;
background: #181825; color: #89b4fa; border: 1px solid #6366f1;
padding: 3px 8px; border-radius: 4px; font-size: 12px; font-family: monospace;
display: none;
}
#toast {
position: fixed; top: 60px; right: 16px; z-index: 2000;
background: #6366f1; color: white; padding: 8px 16px; border-radius: 6px;
font-size: 13px; opacity: 0; transition: opacity 0.3s; pointer-events: none;
}
#toast.show { opacity: 1; }
.page-label {
position: absolute; top: 4px; right: 4px; z-index: 100;
background: rgba(0,0,0,0.6); color: white; padding: 2px 8px;
border-radius: 4px; font-size: 12px; pointer-events: none;
}
line.route-highlight {
stroke: #f87171 !important; stroke-width: 2.5 !important;
opacity: 0.8 !important; stroke-dasharray: none !important;
}
</style>
</head>
<body>
<div id="toolbar">
<span class="title">Route Editor</span>
<div class="page-nav">
<button id="btn-prev">&larr;</button>
<span id="page-indicator">Page 1 / ?</span>
<button id="btn-next">&rarr;</button>
</div>
<div class="sep"></div>
<button id="btn-mode" title="Switch between Objects and Nodes mode (M)">Mode: Objects</button>
<button id="btn-route" title="Toggle enemy route highlight">Route HL</button>
<button id="btn-flip" title="Flip selected object horizontally (H)">Flip H</button>
<div class="sep"></div>
<button id="btn-reset">Reset Page</button>
<button id="btn-copy-changes">Copy Changes</button>
<button id="btn-copy" class="primary">Copy All JSON</button>
<button id="btn-save" class="primary" style="background:#22c55e;border-color:#4ade80;">Save</button>
</div>
<div id="worksheet-container"></div>
<div id="statusbar">
<span id="status-info" class="info">Click an object to select</span>
<span id="status-pos" class="pos"></span>
<span class="keys">M: switch mode | Arrows: move | [ ] rotate | - + scale | H: flip | R: route</span>
</div>
<div id="coord-tooltip"></div>
<div id="toast"></div>
<script src="/src/editor/editor-core.js"></script>
<script>
(function() {
// === Space-route specific state ===
let objects = [];
let nodeEls = [];
let originalState = new Map();
let nodeOrigState = new Map();
let selectedObj = null;
let selectedType = null; // 'obj' or 'node'
let dragging = null;
let dragStart = null;
let routeHighlight = false;
let editMode = 'objects';
// === Init via EditorCore ===
const core = EditorCore.init({
taskType: 'space-route',
serialize: buildConfig,
onReset: resetCurrentPage,
onReady: function(pages, mmToPx) {
setupObjects();
setupNodes();
setupGlobalEvents();
document.getElementById('btn-flip').addEventListener('click', flipSelected);
document.getElementById('btn-mode').addEventListener('click', toggleMode);
document.getElementById('btn-route').addEventListener('click', toggleRouteHL);
window.getConfig = () => buildConfig(false);
window.getChanges = () => buildConfig(true);
}
});
if (!core) return;
// === Setup objects ===
function setupObjects() {
document.querySelectorAll('img.space-obj').forEach((img, idx) => {
const page = img.closest('[data-page]');
const pageNum = page ? parseInt(page.dataset.page) : 0;
const nodeId = img.dataset.nodeId;
const left = parseFloat(img.style.left) || 0;
const top = parseFloat(img.style.top) || 0;
const w = parseFloat(img.style.width) || 24;
const h = parseFloat(img.style.height) || 24;
let rotate = 0, scaleX = 1, scaleY = 1;
if (img.style.transform) {
const rotM = img.style.transform.match(/rotate\(([-\d.]+)deg\)/);
const scxM = img.style.transform.match(/scaleX\(([-\d.]+)\)/);
const scM = img.style.transform.match(/scale\(([-\d.]+)\)/);
if (rotM) rotate = parseFloat(rotM[1]);
if (scxM) scaleX = parseFloat(scxM[1]);
if (scM) scaleX = scaleY = parseFloat(scM[1]);
}
const id = `p${pageNum}-obj${idx}`;
img.dataset.objId = id;
img.dataset.pageNum = pageNum;
img.style.position = 'absolute';
originalState.set(id, { left, top, w, h, rotate, scaleX, scaleY });
img.classList.add('obj-draggable');
img.draggable = false;
objects.push({ el: img, id, pageNum, nodeId, type: img.dataset.type, src: img.src.split('/').pop() });
img.addEventListener('mousedown', (e) => {
e.preventDefault(); e.stopPropagation();
selectObj(img, id);
startDrag(img, e);
});
});
}
// === Setup nodes ===
function setupNodes() {
document.querySelectorAll('[data-node-id]').forEach(nodeDiv => {
if (nodeDiv.tagName === 'IMG') return;
const page = nodeDiv.closest('[data-page]');
const pageNum = page ? parseInt(page.dataset.page) : 0;
const nodeId = nodeDiv.dataset.nodeId;
const left = parseFloat(nodeDiv.style.left) || 0;
const top = parseFloat(nodeDiv.style.top) || 0;
const nid = `p${pageNum}-n${nodeId}`;
nodeDiv.dataset.nid = nid;
nodeDiv.dataset.pageNum = pageNum;
nodeOrigState.set(nid, { left, top });
nodeDiv.classList.add('node-draggable');
nodeEls.push({ el: nodeDiv, nid, pageNum, nodeId });
nodeDiv.addEventListener('mousedown', (e) => {
if (editMode !== 'nodes') return;
e.preventDefault(); e.stopPropagation();
selectNode(nodeDiv, nid);
startDrag(nodeDiv, e);
});
});
}
// === Global events ===
function setupGlobalEvents() {
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
document.addEventListener('keydown', onKeyDown);
document.getElementById('worksheet-container').addEventListener('mousedown', (e) => {
if (!e.target.closest('.obj-draggable')) deselectAll();
});
}
// === Selection ===
function selectObj(el, id) {
deselectAll();
el.classList.add('obj-selected');
selectedObj = el; selectedType = 'obj';
updateObjStatus(el, id);
}
function selectNode(el, nid) {
deselectAll();
el.classList.add('node-selected');
selectedObj = el; selectedType = 'node';
const val = el.querySelector('span')?.textContent || '?';
document.getElementById('status-info').textContent = `NODE | Page ${el.dataset.pageNum} | id:${el.dataset.nodeId} | value:${val}`;
document.getElementById('status-pos').textContent = `pos: ${parseFloat(el.style.left).toFixed(1)}, ${parseFloat(el.style.top).toFixed(1)}mm`;
}
function deselectAll() {
document.querySelectorAll('.obj-selected, .node-selected').forEach(el => el.classList.remove('obj-selected', 'node-selected'));
selectedObj = null; selectedType = null;
document.getElementById('status-info').textContent = editMode === 'objects' ? 'Click an object to select' : 'Click a node to select';
document.getElementById('status-pos').textContent = '';
}
function updateObjStatus(el, id) {
const obj = objects.find(o => o.id === id);
let rotStr = '0', flipStr = '';
if (el.style.transform) {
const rotM = el.style.transform.match(/rotate\(([-\d.]+)deg\)/);
const scxM = el.style.transform.match(/scaleX\(([-\d.]+)\)/);
if (rotM) rotStr = Math.round(parseFloat(rotM[1]));
if (scxM && parseFloat(scxM[1]) < 0) flipStr = ' FLIP';
}
document.getElementById('status-info').textContent = `Page ${el.dataset.pageNum} | node:${obj?.nodeId} | ${obj?.src || ''}`;
document.getElementById('status-pos').textContent = `pos: ${parseFloat(el.style.left).toFixed(1)}, ${parseFloat(el.style.top).toFixed(1)}mm | size: ${parseFloat(el.style.width).toFixed(0)}×${parseFloat(el.style.height).toFixed(0)}mm | rot: ${rotStr}°${flipStr}`;
}
// === Graph helpers ===
function moveNodeAndEdges(el, newLeft, newTop) {
const nodeId = el.dataset.nodeId;
if (!nodeId) return;
const page = el.closest('[data-page]');
if (!page) return;
const nodeDiv = page.querySelector(`[data-node-id="${nodeId}"]`);
if (nodeDiv) { nodeDiv.style.left = newLeft.toFixed(1) + 'mm'; nodeDiv.style.top = newTop.toFixed(1) + 'mm'; }
_updateEdges(page, nodeId, newLeft, newTop);
}
function moveEdgesForNode(nodeDiv, newLeft, newTop) {
const page = nodeDiv.closest('[data-page]');
if (!page) return;
_updateEdges(page, nodeDiv.dataset.nodeId, newLeft, newTop);
}
function _updateEdges(page, nodeId, left, top) {
const svg = page.querySelector('svg');
if (!svg) return;
svg.querySelectorAll('line[data-edge]').forEach(line => {
const [a, b] = line.dataset.edge.split('-');
if (a === nodeId) { line.setAttribute('x1', left.toFixed(1) + 'mm'); line.setAttribute('y1', top.toFixed(1) + 'mm'); }
if (b === nodeId) { line.setAttribute('x2', left.toFixed(1) + 'mm'); line.setAttribute('y2', top.toFixed(1) + 'mm'); }
});
}
// === Dragging ===
function startDrag(el, e) {
dragging = el;
dragStart = { mouseX: e.clientX, mouseY: e.clientY, left: parseFloat(el.style.left) || 0, top: parseFloat(el.style.top) || 0 };
document.body.classList.add('dragging');
}
function onMouseMove(e) {
if (!dragging) return;
e.preventDefault();
const dx = (e.clientX - dragStart.mouseX) / core.mmToPx;
const dy = (e.clientY - dragStart.mouseY) / core.mmToPx;
const newLeft = Math.round((dragStart.left + dx) * 2) / 2;
const newTop = Math.round((dragStart.top + dy) * 2) / 2;
dragging.style.left = newLeft.toFixed(1) + 'mm';
dragging.style.top = newTop.toFixed(1) + 'mm';
if (selectedType === 'obj') {
const w = parseFloat(dragging.style.width) || 24;
const h = parseFloat(dragging.style.height) || 24;
dragging.style.marginLeft = (-w / 2) + 'mm';
dragging.style.marginTop = (-h / 2) + 'mm';
updateObjStatus(dragging, dragging.dataset.objId);
markObjChanged(dragging);
} else if (selectedType === 'node') {
const size = parseFloat(dragging.style.width) || 10;
dragging.style.marginLeft = (-size / 2) + 'mm';
dragging.style.marginTop = (-size / 2) + 'mm';
moveEdgesForNode(dragging, newLeft, newTop);
markNodeChanged(dragging);
document.getElementById('status-pos').textContent = `pos: ${newLeft.toFixed(1)}, ${newTop.toFixed(1)}mm`;
}
core.showTooltip(e, `${newLeft.toFixed(1)}, ${newTop.toFixed(1)}mm`);
}
function onMouseUp() {
if (!dragging) return;
document.body.classList.remove('dragging');
core.hideTooltip();
dragging = null; dragStart = null;
}
// === Keyboard ===
function onKeyDown(e) {
if (e.key === 'm' || e.key === 'M') { toggleMode(); return; }
if (e.key === 'r' || e.key === 'R') { toggleRouteHL(); return; }
if (e.key === 'h' || e.key === 'H') { if (selectedType === 'obj') flipSelected(); return; }
if (e.key === 'Escape') { deselectAll(); return; }
if (!selectedObj) return;
if (selectedType === 'node') {
const step = e.shiftKey ? 5 : 1;
let left = parseFloat(selectedObj.style.left) || 0;
let top = parseFloat(selectedObj.style.top) || 0;
switch (e.key) {
case 'ArrowLeft': left -= step; break;
case 'ArrowRight': left += step; break;
case 'ArrowUp': top -= step; break;
case 'ArrowDown': top += step; break;
default: return;
}
e.preventDefault();
selectedObj.style.left = left.toFixed(1) + 'mm';
selectedObj.style.top = top.toFixed(1) + 'mm';
const size = parseFloat(selectedObj.style.width) || 10;
selectedObj.style.marginLeft = (-size / 2) + 'mm';
selectedObj.style.marginTop = (-size / 2) + 'mm';
moveEdgesForNode(selectedObj, left, top);
markNodeChanged(selectedObj);
document.getElementById('status-pos').textContent = `pos: ${left.toFixed(1)}, ${top.toFixed(1)}mm`;
return;
}
// Object mode
const step = e.shiftKey ? 5 : 1;
let left = parseFloat(selectedObj.style.left) || 0;
let top = parseFloat(selectedObj.style.top) || 0;
let w = parseFloat(selectedObj.style.width) || 24;
let h = parseFloat(selectedObj.style.height) || 24;
const sizeStep = e.shiftKey ? 1 : 2;
const rotStep = e.shiftKey ? 1 : 5;
switch (e.key) {
case 'ArrowLeft': left -= step; break;
case 'ArrowRight': left += step; break;
case 'ArrowUp': top -= step; break;
case 'ArrowDown': top += step; break;
case '[': applyRotate(selectedObj, -rotStep); break;
case ']': applyRotate(selectedObj, rotStep); break;
case '-': case '_':
w = Math.max(8, w - sizeStep); h = Math.max(8, h - sizeStep * (h / w));
selectedObj.style.width = w + 'mm'; selectedObj.style.height = h + 'mm';
selectedObj.style.marginLeft = (-w / 2) + 'mm'; selectedObj.style.marginTop = (-h / 2) + 'mm';
break;
case '=': case '+':
w = Math.min(60, w + sizeStep); h = Math.min(60, h + sizeStep * (h / w));
selectedObj.style.width = w + 'mm'; selectedObj.style.height = h + 'mm';
selectedObj.style.marginLeft = (-w / 2) + 'mm'; selectedObj.style.marginTop = (-h / 2) + 'mm';
break;
default: return;
}
e.preventDefault();
selectedObj.style.left = left.toFixed(1) + 'mm';
selectedObj.style.top = top.toFixed(1) + 'mm';
selectedObj.style.marginLeft = (-parseFloat(selectedObj.style.width) / 2) + 'mm';
selectedObj.style.marginTop = (-parseFloat(selectedObj.style.height) / 2) + 'mm';
updateObjStatus(selectedObj, selectedObj.dataset.objId);
markObjChanged(selectedObj);
}
// === Transform helpers ===
function applyRotate(el, delta) {
let transform = el.style.transform || '';
const rotM = transform.match(/rotate\(([-\d.]+)deg\)/);
let rot = rotM ? parseFloat(rotM[1]) : 0;
rot += delta;
transform = rotM ? transform.replace(/rotate\([-\d.]+deg\)/, `rotate(${rot}deg)`) : transform + ` rotate(${rot}deg)`;
el.style.transform = transform.trim();
markObjChanged(el);
}
function flipSelected() {
if (!selectedObj) return;
let transform = selectedObj.style.transform || '';
const scxM = transform.match(/scaleX\(([-\d.]+)\)/);
let scx = scxM ? (parseFloat(scxM[1]) < 0 ? 1 : -1) : -1;
transform = scxM ? transform.replace(/scaleX\([-\d.]+\)/, `scaleX(${scx})`) : transform + ` scaleX(${scx})`;
selectedObj.style.transform = transform.trim();
updateObjStatus(selectedObj, selectedObj.dataset.objId);
markObjChanged(selectedObj);
}
function toggleMode() {
deselectAll();
editMode = editMode === 'objects' ? 'nodes' : 'objects';
document.body.classList.toggle('node-mode', editMode === 'nodes');
const btn = document.getElementById('btn-mode');
btn.textContent = editMode === 'objects' ? 'Mode: Objects' : 'Mode: Nodes';
btn.classList.toggle('active', editMode === 'nodes');
}
function toggleRouteHL() {
routeHighlight = !routeHighlight;
document.getElementById('btn-route').classList.toggle('active', routeHighlight);
core.pages.forEach(pageEl => {
const routeStr = pageEl.dataset.enemyRoute;
if (!routeStr) return;
const route = routeStr.split(',');
const routeEdges = new Set();
for (let i = 0; i < route.length - 1; i++) {
const a = parseInt(route[i]), b = parseInt(route[i + 1]);
routeEdges.add(`${Math.min(a, b)}-${Math.max(a, b)}`);
}
pageEl.querySelectorAll('line[data-edge]').forEach(line => {
const [ea, eb] = line.dataset.edge.split('-').map(Number);
if (routeEdges.has(`${Math.min(ea, eb)}-${Math.max(ea, eb)}`)) {
line.classList.toggle('route-highlight', routeHighlight);
}
});
});
}
// === Change tracking ===
function buildOrigTransform(orig) {
let t = '';
if (orig.scaleX < 0) t += `scaleX(${orig.scaleX})`;
if (orig.rotate) t += ` rotate(${orig.rotate}deg)`;
return t.trim();
}
function markObjChanged(el) {
const id = el.dataset.objId;
const orig = originalState.get(id);
if (!orig) return;
const changed = Math.abs(orig.left - parseFloat(el.style.left)) > 0.3 ||
Math.abs(orig.top - parseFloat(el.style.top)) > 0.3 ||
Math.abs(orig.w - parseFloat(el.style.width)) > 0.3 ||
Math.abs(orig.h - parseFloat(el.style.height)) > 0.3 ||
(el.style.transform || '') !== buildOrigTransform(orig);
el.classList.toggle('obj-changed', changed);
}
function markNodeChanged(nodeDiv) {
const nid = nodeDiv.dataset.nid;
const orig = nodeOrigState.get(nid);
if (!orig) return;
const changed = Math.abs(orig.left - parseFloat(nodeDiv.style.left)) > 0.3 || Math.abs(orig.top - parseFloat(nodeDiv.style.top)) > 0.3;
nodeDiv.classList.toggle('node-changed', changed);
}
// === Serialization ===
function buildConfig(changesOnly) {
const result = { file: core.docId, pages: [] };
core.pages.forEach((pageEl, pageIndex) => {
const pageObjs = objects.filter(o => o.pageNum === pageIndex + 1);
const items = [];
pageObjs.forEach(obj => {
const el = obj.el;
const left = parseFloat(el.style.left), top = parseFloat(el.style.top);
const w = parseFloat(el.style.width), h = parseFloat(el.style.height);
let rotate = 0, flipH = false;
if (el.style.transform) {
const rotM = el.style.transform.match(/rotate\(([-\d.]+)deg\)/);
const scxM = el.style.transform.match(/scaleX\(([-\d.]+)\)/);
if (rotM) rotate = Math.round(parseFloat(rotM[1]));
if (scxM && parseFloat(scxM[1]) < 0) flipH = true;
}
const orig = originalState.get(obj.id);
if (changesOnly && orig) {
const same = Math.abs(orig.left - left) < 0.3 && Math.abs(orig.top - top) < 0.3 &&
Math.abs(orig.w - w) < 0.3 && Math.abs(orig.h - h) < 0.3 &&
(el.style.transform || '') === buildOrigTransform(orig);
if (same) return;
}
items.push({ nodeId: parseInt(obj.nodeId), type: obj.type, src: obj.src, left: left.toFixed(1) + 'mm', top: top.toFixed(1) + 'mm', w: w.toFixed(0) + 'mm', h: h.toFixed(0) + 'mm', rotate, flipH });
});
const pageNodes = nodeEls.filter(n => n.pageNum === pageIndex + 1);
const movedNodes = [];
pageNodes.forEach(nd => {
const left = parseFloat(nd.el.style.left), top = parseFloat(nd.el.style.top);
const orig = nodeOrigState.get(nd.nid);
if (changesOnly && orig && Math.abs(orig.left - left) < 0.3 && Math.abs(orig.top - top) < 0.3) return;
movedNodes.push({ nodeId: parseInt(nd.nodeId), left: left.toFixed(1) + 'mm', top: top.toFixed(1) + 'mm' });
});
if (items.length > 0 || movedNodes.length > 0) {
const pageData = { page: pageIndex + 1 };
if (items.length > 0) pageData.objects = items;
if (movedNodes.length > 0) pageData.nodes = movedNodes;
result.pages.push(pageData);
}
});
return result;
}
// === Reset ===
function resetCurrentPage(pageNum) {
objects.filter(o => o.pageNum === pageNum).forEach(obj => {
const orig = originalState.get(obj.id);
if (!orig) return;
obj.el.style.left = orig.left + 'mm'; obj.el.style.top = orig.top + 'mm';
obj.el.style.width = orig.w + 'mm'; obj.el.style.height = orig.h + 'mm';
obj.el.style.marginLeft = (-orig.w / 2) + 'mm'; obj.el.style.marginTop = (-orig.h / 2) + 'mm';
obj.el.style.transform = buildOrigTransform(orig);
obj.el.classList.remove('obj-changed');
moveNodeAndEdges(obj.el, orig.left, orig.top);
});
core.showToast(`Page ${pageNum} reset`);
}
})();
</script>
</body>
</html>