Files
wownerogue/html/js/render/zoomControl.js
T

276 lines
12 KiB
JavaScript
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.
(function (root) {
'use strict';
var RK = root.RK = root.RK || {};
var MIN = 0.4, MAX = 4.0;
// Apply the host's current zoom to whatever canvas is mounted in it (2D or WebGL). Re-callable
// after a renderer/mode switch so the new canvas inherits the zoom.
RK.applyZoom = function (host) {
if (!host) return;
var z = host._rkZoom || 1;
var c = host.querySelector('canvas');
if (!c) return;
c.style.transformOrigin = 'center center';
c.style.transform = z === 1 ? '' : 'scale(' + z + ')';
c.style.imageRendering = 'pixelated'; // keep pixel art crisp when zoomed
};
// Wheel + trackpad-pinch zoom on a render host, shared by every mode. Trackpad pinch arrives as
// a wheel event with ctrlKey set; a normal wheel over the canvas zooms instead of scrolling.
RK.attachZoom = function (host) {
if (!host || host._rkZoomAttached) return;
host._rkZoomAttached = true;
host._rkZoom = host._rkZoom || 1;
host.style.overflow = 'hidden';
host.title = 'Scroll / pinch to zoom · double-click to reset';
host.addEventListener('wheel', function (e) {
e.preventDefault();
var factor = Math.exp(-e.deltaY * 0.0015); // smooth, direction-correct
host._rkZoom = Math.max(MIN, Math.min(MAX, (host._rkZoom || 1) * factor));
RK.applyZoom(host);
}, { passive: false });
host.addEventListener('dblclick', function () {
host._rkZoom = 1;
RK.applyZoom(host);
});
};
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)); }
function rendererCanvas(renderer) {
return renderer && (renderer.canvas || (renderer.app && renderer.app.view) ||
(renderer.renderer && renderer.renderer.domElement));
}
// Player-follow camera for fixed viewports (single/multiplayer races and spectator views).
// Renderers only need to expose `canvas` + `focusPoint`; ThreeRenderer instead handles zoom and
// following internally through setZoom(). The older attachZoom API above remains unchanged for
// whole-room previews that intentionally do not follow an avatar.
RK.attachCamera = function (host, opts) {
if (!host) return null;
if (host._rkCamera) return host._rkCamera.api;
opts = opts || {};
var state = host._rkCamera = {
zoom: clamp(Number(opts.zoom) || 1.35, Number(opts.min) || 0.55, Number(opts.max) || 3.2),
defaultZoom: Number(opts.zoom) || 1.35,
fitRatio: Number(opts.fitRatio) || 0,
autoFit: false,
min: Number(opts.min) || 0.55,
max: Number(opts.max) || 3.2,
panX: 0,
panY: 0,
renderer: null,
pointers: {},
pinchDistance: null,
listeners: [],
destroyed: false
};
host.style.position = host.style.position || 'relative';
host.style.overflow = 'hidden';
host.style.touchAction = 'none';
host.style.cursor = 'grab';
function updateReadout() {
if (state.readout) state.readout.textContent = Math.round(state.zoom * 100) + '%';
}
function listen(target, type, handler, options) {
target.addEventListener(type, handler, options);
state.listeners.push({ target: target, type: type, handler: handler, options: options });
}
function fitZoom(renderer, ratio) {
if (!renderer || renderer.name === '3d') return clamp(state.defaultZoom, state.min, state.max);
var canvas = rendererCanvas(renderer);
if (!canvas) return clamp(state.defaultZoom, state.min, state.max);
var cw = canvas.width || canvas.offsetWidth || 1;
var ch = canvas.height || canvas.offsetHeight || 1;
var w = host.clientWidth || host.offsetWidth || 640;
var h = host.clientHeight || host.offsetHeight || 400;
return clamp(Math.min(w / cw, h / ch) * (Number(ratio) || state.fitRatio || 0.72), state.min, state.max);
}
function setZoom(next, manual) {
if (state.destroyed) return;
if (manual) state.autoFit = false;
state.zoom = clamp(Number(next) || 1, state.min, state.max);
updateReadout();
RK.updateCamera(host, state.renderer);
}
function reset() {
if (state.destroyed) return;
state.panX = 0;
state.panY = 0;
state.autoFit = !!(state.fitRatio && state.renderer);
setZoom(state.autoFit ? fitZoom(state.renderer) : state.defaultZoom);
}
function onWheel(e) {
if (state.destroyed) return;
e.preventDefault();
setZoom(state.zoom * Math.exp(-e.deltaY * 0.0015), true);
}
function onDoubleClick(e) {
if (state.destroyed) return;
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
reset();
}
function onPointerDown(e) {
if (state.destroyed) return;
if (e.target && e.target.closest && e.target.closest('.rk-camera-controls')) return;
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
state.dragId = e.pointerId;
host.style.cursor = 'grabbing';
try { host.setPointerCapture(e.pointerId); } catch (_) {}
}
function onPointerMove(e) {
if (state.destroyed) return;
var prev = state.pointers[e.pointerId];
if (!prev) return;
state.pointers[e.pointerId] = { x: e.clientX, y: e.clientY };
var ids = Object.keys(state.pointers);
if (ids.length > 1) {
var a = state.pointers[ids[0]], b = state.pointers[ids[1]];
var dist = Math.hypot(a.x - b.x, a.y - b.y);
if (state.pinchDistance) setZoom(state.zoom * (dist / state.pinchDistance));
state.pinchDistance = dist;
} else {
state.autoFit = false;
state.panX += e.clientX - prev.x;
state.panY += e.clientY - prev.y;
RK.updateCamera(host, state.renderer);
}
}
function releasePointer(e) {
if (state.destroyed) return;
delete state.pointers[e.pointerId];
state.pinchDistance = null;
if (!Object.keys(state.pointers).length) host.style.cursor = 'grab';
}
listen(host, 'wheel', onWheel, { passive: false });
listen(host, 'dblclick', onDoubleClick);
listen(host, 'pointerdown', onPointerDown);
listen(host, 'pointermove', onPointerMove);
listen(host, 'pointerup', releasePointer);
listen(host, 'pointercancel', releasePointer);
if (opts.controls !== false && root.document) {
var controls = root.document.createElement('div');
controls.className = 'rk-camera-controls';
controls.setAttribute('aria-label', 'Camera controls');
controls.style.cssText = 'position:absolute;right:10px;bottom:10px;z-index:20;display:flex;' +
'align-items:center;gap:4px;padding:4px;background:rgba(4,7,10,.82);border:1px solid rgba(255,255,255,.2);' +
'border-radius:7px;box-shadow:0 4px 18px rgba(0,0,0,.45);font:12px ui-monospace,monospace;';
function button(label, title, fn) {
var b = root.document.createElement('button');
b.type = 'button'; b.textContent = label; b.title = title; b.setAttribute('aria-label', title);
b.style.cssText = 'min-width:30px;height:28px;padding:0 7px;background:#151b22;color:#e5e7eb;' +
'border:1px solid #3b4552;border-radius:4px;cursor:pointer;font:inherit;';
listen(b, 'click', function (e) { e.stopPropagation(); fn(); });
controls.appendChild(b);
}
button('−', 'Zoom out', function () { setZoom(state.zoom / 1.18, true); });
state.readout = root.document.createElement('span');
state.readout.style.cssText = 'min-width:42px;text-align:center;color:#a7f3d0;';
controls.appendChild(state.readout);
button('+', 'Zoom in', function () { setZoom(state.zoom * 1.18, true); });
button('⌂', 'Reset camera', reset);
host.appendChild(controls);
state.controls = controls;
}
updateReadout();
if (typeof root.ResizeObserver === 'function') {
state.observer = new root.ResizeObserver(function () {
if (state.destroyed) return;
if (state.autoFit) setZoom(fitZoom(state.renderer));
else RK.updateCamera(host, state.renderer);
});
state.observer.observe(host);
}
state.api = {
update: function (renderer) {
if (!state.destroyed) RK.updateCamera(host, renderer);
},
setZoom: setZoom,
setDefaultZoom: function (zoom) {
if (!state.destroyed) state.defaultZoom = Number(zoom) || state.defaultZoom;
},
fit: function (renderer, ratio) {
if (state.destroyed) return;
state.renderer = renderer || state.renderer;
if (ratio != null) state.fitRatio = Number(ratio) || state.fitRatio;
state.panX = 0;
state.panY = 0;
state.autoFit = !!state.renderer;
setZoom(fitZoom(state.renderer));
},
reset: reset,
getZoom: function () { return state.zoom; },
destroy: function () {
if (state.destroyed) return;
state.destroyed = true;
state.listeners.splice(0).forEach(function (entry) {
if (entry.target && typeof entry.target.removeEventListener === 'function') {
entry.target.removeEventListener(entry.type, entry.handler, entry.options);
}
});
Object.keys(state.pointers).forEach(function (pointerId) {
try {
if (typeof host.releasePointerCapture === 'function') {
host.releasePointerCapture(Number(pointerId));
}
} catch (_) {}
});
state.pointers = {};
state.pinchDistance = null;
host.style.cursor = 'grab';
if (state.observer) {
state.observer.disconnect();
state.observer = null;
}
if (state.controls && state.controls.parentNode) state.controls.parentNode.removeChild(state.controls);
state.controls = null;
state.readout = null;
state.renderer = null;
if (host._rkCamera === state) delete host._rkCamera;
}
};
return state.api;
};
RK.updateCamera = function (host, renderer) {
var state = host && host._rkCamera;
if (!state || !renderer) return;
state.renderer = renderer;
if (renderer.name === '3d') {
if (typeof renderer.setZoom === 'function') renderer.setZoom(state.zoom);
if (typeof renderer.setPan === 'function') renderer.setPan(state.panX, state.panY);
return;
}
var canvas = rendererCanvas(renderer);
if (!canvas) return;
var cw = canvas.width || canvas.offsetWidth || 1;
var ch = canvas.height || canvas.offsetHeight || 1;
var w = host.clientWidth || host.offsetWidth || 640;
var h = host.clientHeight || host.offsetHeight || 400;
var focus = renderer.focusPoint || { x: cw / 2, y: ch / 2 };
var z = state.zoom;
var tx = w / 2 - focus.x * z + state.panX;
var ty = h / 2 - focus.y * z + state.panY;
var sw = cw * z, sh = ch * z;
tx = sw <= w ? (w - sw) / 2 : clamp(tx, w - sw, 0);
ty = sh <= h ? (h - sh) / 2 : clamp(ty, h - sh, 0);
canvas.style.position = 'absolute';
canvas.style.left = '0';
canvas.style.top = '0';
canvas.style.maxWidth = 'none';
canvas.style.maxHeight = 'none';
canvas.style.transformOrigin = '0 0';
canvas.style.imageRendering = 'pixelated';
canvas.style.transition = 'transform 110ms ease-out';
canvas.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + z + ')';
};
})(window);