mirror of
https://github.com/tt-a1i/archify.git
synced 2026-09-28 15:04:37 +08:00
UI/UX only, re-cut from dev so no renderer or authoring-rule changes ride along (those made diagrams noisier in real use and were dropped). - Reader shell: system UI type, grouped toolbar that scrolls with the page, refined style/export menus and navigation dock (Guide on "?", Map only when zoomed), plain-language Path copy and compact picking panel. - Summary rail with node index for every diagram: right by default, collapsible, movable to the bottom, remembered per browser, and collapsed when docking would shrink primary text below 12px. - Raster exports reproduce the Viewer page without controls (title row, canvas card, margins); background now covers non-zero viewBox origins. - Canvas keeps dev's diagram colors and strokes; classic adds only a quiet dot field and lifted nodes. - Remove guided views and story playback (breaking): meta.views is no longer accepted and fails with "remove unsupported property views". - Skill: each diagram request goes into .archify/<type>-<slug>-<timestamp>/. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
518 lines
24 KiB
JavaScript
518 lines
24 KiB
JavaScript
Archify.view = (function () {
|
|
var container = document.querySelector('.diagram-container');
|
|
var svg = container.querySelector('svg');
|
|
var outBtn = container.querySelector('[data-view="out"]');
|
|
var resetBtn = container.querySelector('[data-view="reset"]');
|
|
var resetDetailLabel = resetBtn.querySelector('[data-view-detail]');
|
|
var resetPercentLabel = resetBtn.querySelector('[data-view-percent]');
|
|
var inBtn = container.querySelector('[data-view="in"]');
|
|
var state = { scale: 1, x: 0, y: 0, mode: 'overview' };
|
|
var drag = null;
|
|
var cameraTimer = null;
|
|
var cameraFrame = null;
|
|
var cameraGeneration = 0;
|
|
var cameraTransaction = null;
|
|
var clipFrame = 0;
|
|
var resizeFrame = 0;
|
|
var autoScrollUntil = 0;
|
|
|
|
var viewBox = svg.viewBox && svg.viewBox.baseVal;
|
|
|
|
function clamp() {
|
|
var width = svg.clientWidth || 1;
|
|
var height = svg.clientHeight || 1;
|
|
state.x = Math.min(0, Math.max(width - width * state.scale, state.x));
|
|
state.y = Math.min(0, Math.max(height - height * state.scale, state.y));
|
|
}
|
|
function reducedMotion() {
|
|
return window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
}
|
|
function contentMetrics() {
|
|
if (!viewBox || viewBox.width <= 0 || viewBox.height <= 0) return null;
|
|
var width = svg.clientWidth || 1;
|
|
var height = svg.clientHeight || 1;
|
|
var scale = Math.min(width / viewBox.width, height / viewBox.height);
|
|
return {
|
|
width: width,
|
|
height: height,
|
|
scale: scale,
|
|
offsetX: (width - viewBox.width * scale) / 2,
|
|
offsetY: (height - viewBox.height * scale) / 2
|
|
};
|
|
}
|
|
function logicalViewport() {
|
|
var metrics = contentMetrics();
|
|
if (!metrics) return null;
|
|
var x;
|
|
var y;
|
|
var width;
|
|
var height;
|
|
if (window.innerWidth <= 720 && container.hasAttribute('data-wide-diagram')) {
|
|
x = viewBox.x + container.scrollLeft / metrics.scale;
|
|
y = viewBox.y;
|
|
width = Math.min(viewBox.width, Math.max(1, container.clientWidth / metrics.scale));
|
|
height = viewBox.height;
|
|
} else {
|
|
x = viewBox.x + ((-state.x / state.scale) - metrics.offsetX) / metrics.scale;
|
|
y = viewBox.y + ((-state.y / state.scale) - metrics.offsetY) / metrics.scale;
|
|
width = Math.min(viewBox.width, metrics.width / state.scale / metrics.scale);
|
|
height = Math.min(viewBox.height, metrics.height / state.scale / metrics.scale);
|
|
}
|
|
width = Math.max(1, Math.min(viewBox.width, width));
|
|
height = Math.max(1, Math.min(viewBox.height, height));
|
|
x = Math.max(viewBox.x, Math.min(viewBox.x + viewBox.width - width, x));
|
|
y = Math.max(viewBox.y, Math.min(viewBox.y + viewBox.height - height, y));
|
|
return { x: x, y: y, width: width, height: height, scale: state.scale };
|
|
}
|
|
function detailLevel() {
|
|
if (state.mode === 'semantic') return 'full';
|
|
if (state.scale >= 1.75) return 'full';
|
|
if (state.scale >= 1) return 'read';
|
|
return 'map';
|
|
}
|
|
function renderControls() {
|
|
var semantic = state.mode === 'semantic' && state.scale > 1.01;
|
|
var detail = detailLevel();
|
|
var percent = Math.round(state.scale * 100) + '%';
|
|
var levelLabel = viewerText('viewer.nav.level.' + detail);
|
|
var detailHint = detail === 'map'
|
|
? viewerText('viewer.nav.detail.map')
|
|
: detail === 'read'
|
|
? viewerText('viewer.nav.detail.read')
|
|
: viewerText('viewer.nav.detail.full');
|
|
var resolvedLevel = semantic ? viewerText('viewer.nav.level.auto') : levelLabel;
|
|
var showDetailLevel = semantic || detail !== 'read';
|
|
if (resetDetailLabel) {
|
|
resetDetailLabel.textContent = resolvedLevel;
|
|
resetDetailLabel.hidden = !showDetailLevel;
|
|
}
|
|
if (resetPercentLabel) resetPercentLabel.textContent = percent;
|
|
resetBtn.toggleAttribute('data-detail-visible', showDetailLevel);
|
|
resetBtn.title = viewerText('viewer.nav.camera.title', {
|
|
semantic: semantic ? viewerText('viewer.nav.camera.semantic') : '',
|
|
hint: detailHint
|
|
});
|
|
resetBtn.setAttribute('aria-label', viewerText('viewer.nav.camera', { hint: detailHint }));
|
|
resetBtn.setAttribute('data-detail-level', detail);
|
|
container.setAttribute('data-detail-level', detail);
|
|
container.setAttribute('data-camera-mode', state.mode);
|
|
container.setAttribute('data-camera-indicator', semantic ? 'true' : 'false');
|
|
}
|
|
function clipToViewport(camera) {
|
|
camera = camera || state;
|
|
if (camera.scale <= 1.001) {
|
|
svg.style.removeProperty('clip-path');
|
|
return;
|
|
}
|
|
var width = svg.clientWidth || 1;
|
|
var height = svg.clientHeight || 1;
|
|
var scale = camera.scale;
|
|
var top = Math.max(0, Math.min(height, -camera.y / scale));
|
|
var left = Math.max(0, Math.min(width, -camera.x / scale));
|
|
var right = Math.max(0, Math.min(width, width - (width - camera.x) / scale));
|
|
var bottom = Math.max(0, Math.min(height, height - (height - camera.y) / scale));
|
|
svg.style.clipPath = 'inset(' + [top, right, bottom, left].map(function (value) {
|
|
return Math.round(value * 1000) / 1000 + 'px';
|
|
}).join(' ') + ')';
|
|
}
|
|
function cameraSettled(rendered) {
|
|
return Math.abs(rendered.scale - state.scale) < 0.001 &&
|
|
Math.abs(rendered.x - state.x) < 0.05 &&
|
|
Math.abs(rendered.y - state.y) < 0.05;
|
|
}
|
|
function syncViewportClip() {
|
|
if (clipFrame) cancelAnimationFrame(clipFrame);
|
|
clipFrame = 0;
|
|
function sample() {
|
|
clipFrame = 0;
|
|
var rendered = sampleRenderedState();
|
|
clipToViewport(rendered);
|
|
if (!cameraSettled(rendered)) clipFrame = requestAnimationFrame(sample);
|
|
}
|
|
sample();
|
|
}
|
|
function apply() {
|
|
clamp();
|
|
svg.style.transform = 'translate(' + state.x + 'px,' + state.y + 'px) scale(' + state.scale + ')';
|
|
syncViewportClip();
|
|
renderControls();
|
|
outBtn.disabled = state.scale <= 1;
|
|
inBtn.disabled = state.scale >= 3;
|
|
container.classList.toggle('is-pannable', state.scale > 1);
|
|
svg.setAttribute('data-view-scale', String(state.scale));
|
|
if (Archify.radar && typeof Archify.radar.sync === 'function') Archify.radar.sync();
|
|
if (Archify.viewerChromeLayout && typeof Archify.viewerChromeLayout.schedule === 'function') {
|
|
Archify.viewerChromeLayout.schedule();
|
|
}
|
|
}
|
|
function sampleRenderedState() {
|
|
var transform = '';
|
|
try { transform = getComputedStyle(svg).transform || ''; } catch (_) {}
|
|
var match = transform.match(/^matrix\(([^)]+)\)$/);
|
|
if (!match) return { scale: state.scale, x: state.x, y: state.y, mode: state.mode };
|
|
var values = match[1].split(',').map(Number);
|
|
if (values.length !== 6 || !values.every(Number.isFinite)) {
|
|
return { scale: state.scale, x: state.x, y: state.y, mode: state.mode };
|
|
}
|
|
return { scale: values[0], x: values[4], y: values[5], mode: state.mode };
|
|
}
|
|
function finishCameraTransaction(transaction, outcome) {
|
|
if (!transaction || transaction.settled) return false;
|
|
transaction.settled = true;
|
|
transaction.state = outcome || 'complete';
|
|
if (transaction.frame) cancelAnimationFrame(transaction.frame);
|
|
if (transaction.timer) clearTimeout(transaction.timer);
|
|
transaction.frame = null;
|
|
transaction.timer = null;
|
|
if (cameraTransaction === transaction) cameraTransaction = null;
|
|
cameraFrame = null;
|
|
cameraTimer = null;
|
|
container.classList.remove('is-camera-moving');
|
|
container.classList.remove('is-camera-transaction');
|
|
container.removeAttribute('data-camera-transaction');
|
|
if (Archify.focus && Archify.focus.reposition) Archify.focus.reposition();
|
|
transaction.resolve({ id: transaction.id, state: transaction.state });
|
|
return true;
|
|
}
|
|
function cameraReceipt(target, options) {
|
|
var resolver;
|
|
var transaction = {
|
|
id: ++cameraGeneration,
|
|
state: 'running',
|
|
target: target,
|
|
settled: false,
|
|
frame: null,
|
|
timer: null,
|
|
finished: new Promise(function (resolve) { resolver = resolve; }),
|
|
resolve: resolver,
|
|
cancel: function (reason, commitTarget) {
|
|
if (transaction.settled) return false;
|
|
if (commitTarget && transaction.target) {
|
|
if (Object.prototype.hasOwnProperty.call(transaction.target, 'scrollLeft')) {
|
|
container.scrollLeft = transaction.target.scrollLeft;
|
|
} else {
|
|
state = {
|
|
scale: transaction.target.scale,
|
|
x: transaction.target.x,
|
|
y: transaction.target.y,
|
|
mode: transaction.target.mode
|
|
};
|
|
apply();
|
|
}
|
|
}
|
|
return finishCameraTransaction(transaction, reason || 'cancelled');
|
|
}
|
|
};
|
|
return transaction;
|
|
}
|
|
function stopCameraMotion(reason, commitTarget) {
|
|
if (cameraTransaction && !cameraTransaction.settled) {
|
|
cameraTransaction.cancel(reason || 'cancelled', commitTarget === true);
|
|
return;
|
|
}
|
|
if (cameraTimer) clearTimeout(cameraTimer);
|
|
if (cameraFrame) cancelAnimationFrame(cameraFrame);
|
|
cameraTimer = null;
|
|
cameraFrame = null;
|
|
container.classList.remove('is-camera-moving');
|
|
container.classList.remove('is-camera-transaction');
|
|
container.removeAttribute('data-camera-transaction');
|
|
}
|
|
function interruptCamera(reason) {
|
|
var rendered = sampleRenderedState();
|
|
stopCameraMotion(reason || 'manual', false);
|
|
state = rendered;
|
|
state.mode = 'manual';
|
|
apply();
|
|
renderControls();
|
|
if (Archify.routeProbe && Archify.routeProbe.isJourneyPlaying && Archify.routeProbe.isJourneyPlaying()) {
|
|
Archify.routeProbe.pauseJourney({ preserveElapsed: true, reason: reason || 'manual' });
|
|
}
|
|
}
|
|
function zoom(next, options) {
|
|
options = options || {};
|
|
if (options.manual !== false) interruptCamera();
|
|
var previous = state.scale;
|
|
next = Math.max(1, Math.min(3, Math.round(next * 4) / 4));
|
|
if (next === previous) return;
|
|
var centerX = (svg.clientWidth || 1) / 2;
|
|
var centerY = (svg.clientHeight || 1) / 2;
|
|
var contentX = (centerX - state.x) / previous;
|
|
var contentY = (centerY - state.y) / previous;
|
|
state.scale = next;
|
|
state.x = centerX - contentX * next;
|
|
state.y = centerY - contentY * next;
|
|
apply();
|
|
}
|
|
function reset(options) {
|
|
options = options || {};
|
|
if (options.automatic !== true) interruptCamera();
|
|
else stopCameraMotion('reset', false);
|
|
state = { scale: 1, x: 0, y: 0, mode: 'overview' };
|
|
apply();
|
|
}
|
|
function centerAt(logicalX, logicalY, options) {
|
|
options = options || {};
|
|
logicalX = Number(logicalX);
|
|
logicalY = Number(logicalY);
|
|
var metrics = contentMetrics();
|
|
if (!metrics || !Number.isFinite(logicalX) || !Number.isFinite(logicalY)) return false;
|
|
interruptCamera();
|
|
if (window.innerWidth <= 720 && container.hasAttribute('data-wide-diagram')) {
|
|
state.scale = 1;
|
|
state.x = 0;
|
|
state.y = 0;
|
|
state.mode = 'manual';
|
|
apply();
|
|
var mobileTarget = (logicalX - viewBox.x) * metrics.scale - container.clientWidth / 2;
|
|
mobileTarget = Math.max(0, Math.min(svg.clientWidth - container.clientWidth, mobileTarget));
|
|
autoScrollUntil = Date.now() + 80;
|
|
try { container.scrollTo({ left: mobileTarget, behavior: options.instant ? 'auto' : 'smooth' }); }
|
|
catch (_) { container.scrollLeft = mobileTarget; }
|
|
return true;
|
|
}
|
|
var minimumScale = Math.max(1, Math.min(3, Number(options.minimumScale) || 1));
|
|
var requestedScale = Number(options.scale);
|
|
state.scale = Math.max(minimumScale, Math.min(3, Number.isFinite(requestedScale) ? requestedScale : state.scale));
|
|
var contentX = metrics.offsetX + (logicalX - viewBox.x) * metrics.scale;
|
|
var contentY = metrics.offsetY + (logicalY - viewBox.y) * metrics.scale;
|
|
state.x = metrics.width / 2 - contentX * state.scale;
|
|
state.y = metrics.height / 2 - contentY * state.scale;
|
|
state.mode = 'manual';
|
|
apply();
|
|
if (Archify.focus && Archify.focus.reposition) Archify.focus.reposition();
|
|
return true;
|
|
}
|
|
function semanticIds(ids, includeNeighbors) {
|
|
var seeds = Object.create(null);
|
|
var wanted = Object.create(null);
|
|
(ids || []).forEach(function (id) { seeds[id] = true; wanted[id] = true; });
|
|
if (includeNeighbors) {
|
|
Array.prototype.forEach.call(svg.querySelectorAll('[data-edge-from][data-edge-to]'), function (edge) {
|
|
var from = edge.getAttribute('data-edge-from');
|
|
var to = edge.getAttribute('data-edge-to');
|
|
if (seeds[from] || seeds[to]) { wanted[from] = true; wanted[to] = true; }
|
|
});
|
|
}
|
|
return wanted;
|
|
}
|
|
function boxesFor(ids, includeNeighbors) {
|
|
var wanted = semanticIds(ids, includeNeighbors);
|
|
return Array.prototype.slice.call(svg.querySelectorAll('[data-node-id]'))
|
|
.filter(function (node) { return wanted[node.getAttribute('data-node-id')]; })
|
|
.map(function (node) {
|
|
try { return node.getBBox(); } catch (_) { return null; }
|
|
})
|
|
.filter(Boolean);
|
|
}
|
|
function frameDesktop(ids, options) {
|
|
options = options || {};
|
|
var boxes = boxesFor(ids, options.includeNeighbors === true);
|
|
if (!boxes.length || !viewBox || viewBox.width <= 0 || viewBox.height <= 0) return false;
|
|
var svgWidth = svg.clientWidth || 1;
|
|
var svgHeight = svg.clientHeight || 1;
|
|
var contentScale = Math.min(svgWidth / viewBox.width, svgHeight / viewBox.height);
|
|
var contentOffsetX = (svgWidth - viewBox.width * contentScale) / 2;
|
|
var contentOffsetY = (svgHeight - viewBox.height * contentScale) / 2;
|
|
var minX = Math.min.apply(Math, boxes.map(function (box) { return box.x; }));
|
|
var minY = Math.min.apply(Math, boxes.map(function (box) { return box.y; }));
|
|
var maxX = Math.max.apply(Math, boxes.map(function (box) { return box.x + box.width; }));
|
|
var maxY = Math.max.apply(Math, boxes.map(function (box) { return box.y + box.height; }));
|
|
var bounds = {
|
|
x: contentOffsetX + (minX - viewBox.x) * contentScale,
|
|
y: contentOffsetY + (minY - viewBox.y) * contentScale,
|
|
width: Math.max(1, (maxX - minX) * contentScale),
|
|
height: Math.max(1, (maxY - minY) * contentScale)
|
|
};
|
|
var padding = options.padding || 48;
|
|
var left = padding;
|
|
var right = svgWidth - padding;
|
|
var top = padding;
|
|
var bottom = svgHeight - Math.max(padding, 72);
|
|
var containerRect = container.getBoundingClientRect();
|
|
var visibleTop = Math.max(0, -containerRect.top);
|
|
var visibleBottom = Math.min(svgHeight, window.innerHeight - containerRect.top);
|
|
if (visibleBottom - visibleTop >= 240) {
|
|
top = Math.max(top, visibleTop + padding);
|
|
bottom = Math.min(bottom, visibleBottom - Math.max(padding, 72));
|
|
}
|
|
var chip = document.getElementById('focus-chip');
|
|
if (chip && !chip.hidden) {
|
|
var lensEnd = chip.offsetLeft + chip.offsetWidth + 24 - (svg.offsetLeft || 0);
|
|
left = Math.max(left, Math.min(svgWidth * 0.42, lensEnd));
|
|
}
|
|
var routeReceipt = document.getElementById('route-probe');
|
|
if (routeReceipt && !routeReceipt.hidden && routeReceipt.hasAttribute('data-route-journey')) {
|
|
var receiptTop = routeReceipt.offsetTop;
|
|
var receiptBottom = receiptTop + routeReceipt.offsetHeight;
|
|
if (receiptTop < svgHeight / 2) top = Math.max(top, receiptBottom + 24);
|
|
else bottom = Math.min(bottom, receiptTop - 24);
|
|
}
|
|
if (right <= left || bottom <= top) return false;
|
|
var maxScale = options.maxScale || (options.includeNeighbors ? 1.9 : 2.15);
|
|
var targetScale = Math.min((right - left) / bounds.width, (bottom - top) / bounds.height) * 0.9;
|
|
targetScale = Math.max(1, Math.min(maxScale, targetScale));
|
|
if (targetScale < 1.08) targetScale = 1;
|
|
var target = {
|
|
scale: Math.round(targetScale * 100) / 100,
|
|
x: 0,
|
|
y: 0,
|
|
mode: 'semantic'
|
|
};
|
|
target.x = (left + right) / 2 - (bounds.x + bounds.width / 2) * target.scale;
|
|
target.y = (top + bottom) / 2 - (bounds.y + bounds.height / 2) * target.scale;
|
|
var start = sampleRenderedState();
|
|
stopCameraMotion('replaced', false);
|
|
var transaction = cameraReceipt(target, options);
|
|
cameraTransaction = transaction;
|
|
var instant = options.instant === true || reducedMotion() || document.hidden;
|
|
if (instant) {
|
|
state = target;
|
|
apply();
|
|
finishCameraTransaction(transaction, reducedMotion() ? 'reduced-motion' : (document.hidden ? 'hidden' : 'complete'));
|
|
return transaction;
|
|
}
|
|
var duration = Math.max(180, Math.min(520, Number(options.duration) || 420));
|
|
var startedAt = 0;
|
|
state = start;
|
|
state.mode = 'semantic';
|
|
apply();
|
|
container.classList.add('is-camera-moving');
|
|
container.classList.add('is-camera-transaction');
|
|
container.setAttribute('data-camera-transaction', String(transaction.id));
|
|
var step = function (timestamp) {
|
|
if (cameraTransaction !== transaction || transaction.settled) return;
|
|
if (!startedAt) startedAt = timestamp;
|
|
var fraction = Math.max(0, Math.min(1, (timestamp - startedAt) / duration));
|
|
var eased = 1 - Math.pow(1 - fraction, 3);
|
|
state = {
|
|
scale: start.scale + (target.scale - start.scale) * eased,
|
|
x: start.x + (target.x - start.x) * eased,
|
|
y: start.y + (target.y - start.y) * eased,
|
|
mode: 'semantic'
|
|
};
|
|
apply();
|
|
if (fraction < 1) {
|
|
transaction.frame = requestAnimationFrame(step);
|
|
cameraFrame = transaction.frame;
|
|
} else {
|
|
state = target;
|
|
apply();
|
|
finishCameraTransaction(transaction, 'complete');
|
|
}
|
|
};
|
|
transaction.frame = requestAnimationFrame(step);
|
|
cameraFrame = transaction.frame;
|
|
return transaction;
|
|
}
|
|
function reveal(ids, options) {
|
|
options = options || {};
|
|
if (window.innerWidth > 720) return frameDesktop(ids, options);
|
|
stopCameraMotion('replaced', false);
|
|
state.scale = 1;
|
|
state.x = 0;
|
|
state.y = 0;
|
|
state.mode = 'semantic';
|
|
apply();
|
|
if (!container.hasAttribute('data-wide-diagram')) {
|
|
var contained = cameraReceipt({ scale: 1, x: 0, y: 0, mode: 'semantic' }, options);
|
|
cameraTransaction = contained;
|
|
finishCameraTransaction(contained, 'complete');
|
|
return contained;
|
|
}
|
|
var boxes = boxesFor(ids, options.includeNeighbors === true);
|
|
if (!boxes.length || !viewBox || viewBox.width <= 0) return false;
|
|
var minX = Math.min.apply(Math, boxes.map(function (box) { return box.x; }));
|
|
var maxX = Math.max.apply(Math, boxes.map(function (box) { return box.x + box.width; }));
|
|
var center = (((minX + maxX) / 2 - viewBox.x) / viewBox.width) * (svg.clientWidth || 1);
|
|
var target = Math.max(0, Math.min(svg.clientWidth - container.clientWidth, center - container.clientWidth / 2));
|
|
var transaction = cameraReceipt({ scrollLeft: target }, options);
|
|
cameraTransaction = transaction;
|
|
var instant = options.instant === true || reducedMotion() || document.hidden;
|
|
autoScrollUntil = Date.now() + (instant ? 50 : 470);
|
|
try { container.scrollTo({ left: target, behavior: instant ? 'auto' : 'smooth' }); }
|
|
catch (_) { container.scrollLeft = target; }
|
|
if (instant) finishCameraTransaction(transaction, reducedMotion() ? 'reduced-motion' : (document.hidden ? 'hidden' : 'complete'));
|
|
else {
|
|
transaction.timer = setTimeout(function () { finishCameraTransaction(transaction, 'complete'); }, 460);
|
|
cameraTimer = transaction.timer;
|
|
container.classList.add('is-camera-moving');
|
|
container.setAttribute('data-camera-transaction', String(transaction.id));
|
|
}
|
|
return transaction;
|
|
}
|
|
function syncSemantic() {
|
|
var active = Archify.focus && typeof Archify.focus.active === 'function' ? Archify.focus.active() : null;
|
|
if (typeof active === 'string') return reveal([active], { includeNeighbors: true, reason: 'focus-sync' });
|
|
if (Array.isArray(active) && active.length) return reveal(active, { reason: 'selection-sync' });
|
|
return false;
|
|
}
|
|
function pinControls() {
|
|
container.style.setProperty('--archify-scroll-x', container.scrollLeft + 'px');
|
|
}
|
|
function onScroll() {
|
|
pinControls();
|
|
if (Archify.radar && typeof Archify.radar.sync === 'function') Archify.radar.sync();
|
|
if (window.innerWidth <= 720 && container.hasAttribute('data-wide-diagram') && Date.now() > autoScrollUntil) {
|
|
interruptCamera();
|
|
}
|
|
}
|
|
function onPointerEnd(event) {
|
|
if (!drag) return;
|
|
var moved = drag.moved;
|
|
drag = null;
|
|
container.classList.remove('is-panning');
|
|
try { container.releasePointerCapture(event.pointerId); } catch (_) {}
|
|
if (moved) {
|
|
container.setAttribute('data-just-panned', 'true');
|
|
setTimeout(function () { container.removeAttribute('data-just-panned'); }, 80);
|
|
}
|
|
}
|
|
|
|
inBtn.addEventListener('click', function () { zoom(state.scale + 0.25); });
|
|
outBtn.addEventListener('click', function () { zoom(state.scale - 0.25); });
|
|
resetBtn.addEventListener('click', reset);
|
|
container.addEventListener('pointerdown', function (event) {
|
|
if (state.scale <= 1 || event.button !== 0 || event.target.closest('.diagram-nav, .focus-chip, .node-finder, .diagram-guide, .overview-map, .route-probe, .semantic-lens') || event.target.closest('[data-node-id]') || event.target.closest('[data-relationship-hit-key]')) return;
|
|
interruptCamera();
|
|
drag = { startX: event.clientX, startY: event.clientY, x: state.x, y: state.y, moved: false };
|
|
container.classList.add('is-panning');
|
|
try { container.setPointerCapture(event.pointerId); } catch (_) {}
|
|
});
|
|
container.addEventListener('pointermove', function (event) {
|
|
if (!drag) return;
|
|
var dx = event.clientX - drag.startX;
|
|
var dy = event.clientY - drag.startY;
|
|
if (Math.abs(dx) + Math.abs(dy) > 3) drag.moved = true;
|
|
state.x = drag.x + dx;
|
|
state.y = drag.y + dy;
|
|
apply();
|
|
});
|
|
container.addEventListener('pointerup', onPointerEnd);
|
|
container.addEventListener('pointercancel', onPointerEnd);
|
|
container.addEventListener('scroll', onScroll, { passive: true });
|
|
window.addEventListener('resize', function () {
|
|
if (resizeFrame) cancelAnimationFrame(resizeFrame);
|
|
resizeFrame = requestAnimationFrame(function () {
|
|
resizeFrame = 0;
|
|
if (state.mode === 'semantic') syncSemantic();
|
|
else apply();
|
|
});
|
|
});
|
|
window.addEventListener('hashchange', function () { requestAnimationFrame(syncSemantic); });
|
|
apply();
|
|
pinControls();
|
|
requestAnimationFrame(syncSemantic);
|
|
|
|
return {
|
|
zoomIn: function () { zoom(state.scale + 0.25); },
|
|
zoomOut: function () { zoom(state.scale - 0.25); },
|
|
reset: reset,
|
|
reveal: reveal,
|
|
centerAt: centerAt,
|
|
logicalViewport: logicalViewport,
|
|
sync: syncSemantic,
|
|
state: function () { return { scale: state.scale, x: state.x, y: state.y, mode: state.mode }; }
|
|
};
|
|
})();
|