Add hold-to-inspect viewer panels
This commit is contained in:
parent
5a56f1fa1e
commit
6108f001be
3 changed files with 307 additions and 8 deletions
|
|
@ -73,6 +73,13 @@ type PhaseFieldPrepInfo = {
|
|||
cellY: number;
|
||||
};
|
||||
|
||||
type ViewerPanelKey = "left" | "right";
|
||||
|
||||
type ViewerPanelState = {
|
||||
asset: ImageAsset | null;
|
||||
label: string;
|
||||
};
|
||||
|
||||
type AppState = {
|
||||
file: File | null;
|
||||
jobId: string | null;
|
||||
|
|
@ -234,6 +241,12 @@ let panStartScrollTop = 0;
|
|||
let offscreenCanvas: HTMLCanvasElement | null = null;
|
||||
let offscreenContext: CanvasRenderingContext2D | null = null;
|
||||
let singlePixel = new ImageData(1, 1);
|
||||
let viewerInspect: { panel: ViewerPanelKey; pointerId: number; clientX: number; clientY: number } | null = null;
|
||||
let viewerRenderQueued = false;
|
||||
let currentViewerPanels: Record<ViewerPanelKey, ViewerPanelState> = {
|
||||
left: { asset: null, label: "Input" },
|
||||
right: { asset: null, label: "Output" },
|
||||
};
|
||||
|
||||
function clampByte(value: number): number {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
|
|
@ -378,6 +391,62 @@ async function drawAsset(canvas: HTMLCanvasElement, asset: ImageAsset | null): P
|
|||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
async function drawAssetInspection(
|
||||
canvas: HTMLCanvasElement,
|
||||
asset: ImageAsset | null,
|
||||
pointer: { clientX: number; clientY: number },
|
||||
): Promise<void> {
|
||||
if (!asset) {
|
||||
await drawAsset(canvas, asset);
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const image = await loadImage(asset);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const displayWidth = Math.max(1, Math.round(rect.width));
|
||||
const displayHeight = Math.max(1, Math.round(rect.height));
|
||||
if (displayWidth <= 0 || displayHeight <= 0) {
|
||||
await drawAsset(canvas, asset);
|
||||
return;
|
||||
}
|
||||
const scale = Math.max(1, Math.ceil(window.devicePixelRatio || 1));
|
||||
canvas.width = displayWidth * scale;
|
||||
canvas.height = displayHeight * scale;
|
||||
context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.clearRect(0, 0, displayWidth, displayHeight);
|
||||
context.fillStyle = "#081012";
|
||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||
|
||||
const imageWidth = image.naturalWidth || asset.width;
|
||||
const imageHeight = image.naturalHeight || asset.height;
|
||||
const viewportWidth = Math.min(displayWidth, imageWidth);
|
||||
const viewportHeight = Math.min(displayHeight, imageHeight);
|
||||
const pointerX = Math.max(0, Math.min(displayWidth, pointer.clientX - rect.left));
|
||||
const pointerY = Math.max(0, Math.min(displayHeight, pointer.clientY - rect.top));
|
||||
const centerX = imageWidth <= displayWidth ? imageWidth * 0.5 : (pointerX / displayWidth) * imageWidth;
|
||||
const centerY = imageHeight <= displayHeight ? imageHeight * 0.5 : (pointerY / displayHeight) * imageHeight;
|
||||
const sourceX = Math.max(0, Math.min(imageWidth - viewportWidth, Math.round(centerX - viewportWidth * 0.5)));
|
||||
const sourceY = Math.max(0, Math.min(imageHeight - viewportHeight, Math.round(centerY - viewportHeight * 0.5)));
|
||||
const destX = Math.floor((displayWidth - viewportWidth) * 0.5);
|
||||
const destY = Math.floor((displayHeight - viewportHeight) * 0.5);
|
||||
|
||||
context.drawImage(
|
||||
image,
|
||||
sourceX,
|
||||
sourceY,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
destX,
|
||||
destY,
|
||||
viewportWidth,
|
||||
viewportHeight,
|
||||
);
|
||||
}
|
||||
|
||||
async function fileToImageAsset(file: File): Promise<ImageAsset> {
|
||||
const dataUrl = await new Promise<string>((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
|
@ -593,7 +662,7 @@ function renderSummary(): void {
|
|||
}
|
||||
}
|
||||
|
||||
async function renderViewer(): Promise<void> {
|
||||
function resolveViewerPanels(): Record<ViewerPanelKey, ViewerPanelState> {
|
||||
const frame = getSelectedFrame();
|
||||
const debugViewerStage = new Set(["inference", "analysis", "selection", "rerank", "solver", "cleanup"]);
|
||||
const showDebugViewer = debugViewerStage.has(state.stageKey);
|
||||
|
|
@ -627,9 +696,40 @@ async function renderViewer(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
leftVizLabel.textContent = leftLabel;
|
||||
rightVizLabel.textContent = rightLabel;
|
||||
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]);
|
||||
return {
|
||||
left: { asset: leftAsset, label: leftLabel },
|
||||
right: { asset: rightAsset, label: rightLabel },
|
||||
};
|
||||
}
|
||||
|
||||
function syncViewerInspectCursor(): void {
|
||||
leftCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "left");
|
||||
rightCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "right");
|
||||
}
|
||||
|
||||
function scheduleViewerRender(): void {
|
||||
if (viewerRenderQueued) {
|
||||
return;
|
||||
}
|
||||
viewerRenderQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
viewerRenderQueued = false;
|
||||
void renderViewer();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderViewer(): Promise<void> {
|
||||
currentViewerPanels = resolveViewerPanels();
|
||||
leftVizLabel.textContent = currentViewerPanels.left.label;
|
||||
rightVizLabel.textContent = currentViewerPanels.right.label;
|
||||
await Promise.all([
|
||||
viewerInspect?.panel === "left"
|
||||
? drawAssetInspection(leftCanvas, currentViewerPanels.left.asset, viewerInspect)
|
||||
: drawAsset(leftCanvas, currentViewerPanels.left.asset),
|
||||
viewerInspect?.panel === "right"
|
||||
? drawAssetInspection(rightCanvas, currentViewerPanels.right.asset, viewerInspect)
|
||||
: drawAsset(rightCanvas, currentViewerPanels.right.asset),
|
||||
]);
|
||||
}
|
||||
|
||||
function drawWrappedCanvasText(
|
||||
|
|
@ -1011,6 +1111,48 @@ function exportEditorPng(): void {
|
|||
link.click();
|
||||
}
|
||||
|
||||
function beginViewerInspect(panel: ViewerPanelKey, event: PointerEvent): void {
|
||||
if (event.button !== 0 || !currentViewerPanels[panel].asset) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
viewerInspect = {
|
||||
panel,
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
};
|
||||
const canvas = panel === "left" ? leftCanvas : rightCanvas;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
syncViewerInspectCursor();
|
||||
scheduleViewerRender();
|
||||
}
|
||||
|
||||
function updateViewerInspect(panel: ViewerPanelKey, event: PointerEvent): void {
|
||||
if (!viewerInspect || viewerInspect.panel !== panel || viewerInspect.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
viewerInspect.clientX = event.clientX;
|
||||
viewerInspect.clientY = event.clientY;
|
||||
scheduleViewerRender();
|
||||
}
|
||||
|
||||
function endViewerInspect(pointerId: number | null = null): void {
|
||||
if (!viewerInspect) {
|
||||
return;
|
||||
}
|
||||
if (pointerId !== null && viewerInspect.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const canvas = viewerInspect.panel === "left" ? leftCanvas : rightCanvas;
|
||||
if (canvas.hasPointerCapture(viewerInspect.pointerId)) {
|
||||
canvas.releasePointerCapture(viewerInspect.pointerId);
|
||||
}
|
||||
viewerInspect = null;
|
||||
syncViewerInspectCursor();
|
||||
scheduleViewerRender();
|
||||
}
|
||||
|
||||
function beginPan(event: PointerEvent): void {
|
||||
panning = true;
|
||||
activePanPointerId = event.pointerId;
|
||||
|
|
@ -1438,10 +1580,29 @@ window.addEventListener("keyup", (event) => {
|
|||
window.addEventListener("blur", () => {
|
||||
state.altHeld = false;
|
||||
painting = false;
|
||||
endViewerInspect();
|
||||
endPan();
|
||||
syncEditorCursorState();
|
||||
});
|
||||
|
||||
for (const [panel, canvas] of [
|
||||
["left", leftCanvas],
|
||||
["right", rightCanvas],
|
||||
] as const) {
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
beginViewerInspect(panel, event);
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
updateViewerInspect(panel, event);
|
||||
});
|
||||
canvas.addEventListener("pointerup", (event) => {
|
||||
endViewerInspect(event.pointerId);
|
||||
});
|
||||
canvas.addEventListener("pointercancel", (event) => {
|
||||
endViewerInspect(event.pointerId);
|
||||
});
|
||||
}
|
||||
|
||||
editorSurface.addEventListener("mousedown", (event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
|
|
@ -1560,6 +1721,7 @@ window.addEventListener("resize", () => {
|
|||
|
||||
setPaintColor(state.paintColor);
|
||||
syncEditorCursorState();
|
||||
syncViewerInspectCursor();
|
||||
zoomValue.textContent = `${state.zoom}x`;
|
||||
renderEventLog();
|
||||
void renderEverything();
|
||||
|
|
|
|||
|
|
@ -117,6 +117,12 @@ let panStartScrollTop = 0;
|
|||
let offscreenCanvas = null;
|
||||
let offscreenContext = null;
|
||||
let singlePixel = new ImageData(1, 1);
|
||||
let viewerInspect = null;
|
||||
let viewerRenderQueued = false;
|
||||
let currentViewerPanels = {
|
||||
left: { asset: null, label: "Input" },
|
||||
right: { asset: null, label: "Output" },
|
||||
};
|
||||
function clampByte(value) {
|
||||
return Math.max(0, Math.min(255, Math.round(value)));
|
||||
}
|
||||
|
|
@ -246,6 +252,45 @@ async function drawAsset(canvas, asset) {
|
|||
context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
async function drawAssetInspection(canvas, asset, pointer) {
|
||||
if (!asset) {
|
||||
await drawAsset(canvas, asset);
|
||||
return;
|
||||
}
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
return;
|
||||
}
|
||||
const image = await loadImage(asset);
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const displayWidth = Math.max(1, Math.round(rect.width));
|
||||
const displayHeight = Math.max(1, Math.round(rect.height));
|
||||
if (displayWidth <= 0 || displayHeight <= 0) {
|
||||
await drawAsset(canvas, asset);
|
||||
return;
|
||||
}
|
||||
const scale = Math.max(1, Math.ceil(window.devicePixelRatio || 1));
|
||||
canvas.width = displayWidth * scale;
|
||||
canvas.height = displayHeight * scale;
|
||||
context.setTransform(scale, 0, 0, scale, 0, 0);
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.clearRect(0, 0, displayWidth, displayHeight);
|
||||
context.fillStyle = "#081012";
|
||||
context.fillRect(0, 0, displayWidth, displayHeight);
|
||||
const imageWidth = image.naturalWidth || asset.width;
|
||||
const imageHeight = image.naturalHeight || asset.height;
|
||||
const viewportWidth = Math.min(displayWidth, imageWidth);
|
||||
const viewportHeight = Math.min(displayHeight, imageHeight);
|
||||
const pointerX = Math.max(0, Math.min(displayWidth, pointer.clientX - rect.left));
|
||||
const pointerY = Math.max(0, Math.min(displayHeight, pointer.clientY - rect.top));
|
||||
const centerX = imageWidth <= displayWidth ? imageWidth * 0.5 : (pointerX / displayWidth) * imageWidth;
|
||||
const centerY = imageHeight <= displayHeight ? imageHeight * 0.5 : (pointerY / displayHeight) * imageHeight;
|
||||
const sourceX = Math.max(0, Math.min(imageWidth - viewportWidth, Math.round(centerX - viewportWidth * 0.5)));
|
||||
const sourceY = Math.max(0, Math.min(imageHeight - viewportHeight, Math.round(centerY - viewportHeight * 0.5)));
|
||||
const destX = Math.floor((displayWidth - viewportWidth) * 0.5);
|
||||
const destY = Math.floor((displayHeight - viewportHeight) * 0.5);
|
||||
context.drawImage(image, sourceX, sourceY, viewportWidth, viewportHeight, destX, destY, viewportWidth, viewportHeight);
|
||||
}
|
||||
async function fileToImageAsset(file) {
|
||||
const dataUrl = await new Promise((resolve, reject) => {
|
||||
const reader = new FileReader();
|
||||
|
|
@ -447,7 +492,7 @@ function renderSummary() {
|
|||
summaryPanel.appendChild(card);
|
||||
}
|
||||
}
|
||||
async function renderViewer() {
|
||||
function resolveViewerPanels() {
|
||||
const frame = getSelectedFrame();
|
||||
const debugViewerStage = new Set(["inference", "analysis", "selection", "rerank", "solver", "cleanup"]);
|
||||
const showDebugViewer = debugViewerStage.has(state.stageKey);
|
||||
|
|
@ -480,9 +525,37 @@ async function renderViewer() {
|
|||
rightLabel = "Cleaned Output";
|
||||
}
|
||||
}
|
||||
leftVizLabel.textContent = leftLabel;
|
||||
rightVizLabel.textContent = rightLabel;
|
||||
await Promise.all([drawAsset(leftCanvas, leftAsset), drawAsset(rightCanvas, rightAsset)]);
|
||||
return {
|
||||
left: { asset: leftAsset, label: leftLabel },
|
||||
right: { asset: rightAsset, label: rightLabel },
|
||||
};
|
||||
}
|
||||
function syncViewerInspectCursor() {
|
||||
leftCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "left");
|
||||
rightCanvas.classList.toggle("is-inspecting", viewerInspect?.panel === "right");
|
||||
}
|
||||
function scheduleViewerRender() {
|
||||
if (viewerRenderQueued) {
|
||||
return;
|
||||
}
|
||||
viewerRenderQueued = true;
|
||||
requestAnimationFrame(() => {
|
||||
viewerRenderQueued = false;
|
||||
void renderViewer();
|
||||
});
|
||||
}
|
||||
async function renderViewer() {
|
||||
currentViewerPanels = resolveViewerPanels();
|
||||
leftVizLabel.textContent = currentViewerPanels.left.label;
|
||||
rightVizLabel.textContent = currentViewerPanels.right.label;
|
||||
await Promise.all([
|
||||
viewerInspect?.panel === "left"
|
||||
? drawAssetInspection(leftCanvas, currentViewerPanels.left.asset, viewerInspect)
|
||||
: drawAsset(leftCanvas, currentViewerPanels.left.asset),
|
||||
viewerInspect?.panel === "right"
|
||||
? drawAssetInspection(rightCanvas, currentViewerPanels.right.asset, viewerInspect)
|
||||
: drawAsset(rightCanvas, currentViewerPanels.right.asset),
|
||||
]);
|
||||
}
|
||||
function drawWrappedCanvasText(context, text, options) {
|
||||
const { x, y, maxWidth, lineHeight } = options;
|
||||
|
|
@ -831,6 +904,45 @@ function exportEditorPng() {
|
|||
link.download = (state.file?.name?.replace(/\.[^.]+$/, "") || "repixelized") + "-edited.png";
|
||||
link.click();
|
||||
}
|
||||
function beginViewerInspect(panel, event) {
|
||||
if (event.button !== 0 || !currentViewerPanels[panel].asset) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
viewerInspect = {
|
||||
panel,
|
||||
pointerId: event.pointerId,
|
||||
clientX: event.clientX,
|
||||
clientY: event.clientY,
|
||||
};
|
||||
const canvas = panel === "left" ? leftCanvas : rightCanvas;
|
||||
canvas.setPointerCapture(event.pointerId);
|
||||
syncViewerInspectCursor();
|
||||
scheduleViewerRender();
|
||||
}
|
||||
function updateViewerInspect(panel, event) {
|
||||
if (!viewerInspect || viewerInspect.panel !== panel || viewerInspect.pointerId !== event.pointerId) {
|
||||
return;
|
||||
}
|
||||
viewerInspect.clientX = event.clientX;
|
||||
viewerInspect.clientY = event.clientY;
|
||||
scheduleViewerRender();
|
||||
}
|
||||
function endViewerInspect(pointerId = null) {
|
||||
if (!viewerInspect) {
|
||||
return;
|
||||
}
|
||||
if (pointerId !== null && viewerInspect.pointerId !== pointerId) {
|
||||
return;
|
||||
}
|
||||
const canvas = viewerInspect.panel === "left" ? leftCanvas : rightCanvas;
|
||||
if (canvas.hasPointerCapture(viewerInspect.pointerId)) {
|
||||
canvas.releasePointerCapture(viewerInspect.pointerId);
|
||||
}
|
||||
viewerInspect = null;
|
||||
syncViewerInspectCursor();
|
||||
scheduleViewerRender();
|
||||
}
|
||||
function beginPan(event) {
|
||||
panning = true;
|
||||
activePanPointerId = event.pointerId;
|
||||
|
|
@ -1206,9 +1318,27 @@ window.addEventListener("keyup", (event) => {
|
|||
window.addEventListener("blur", () => {
|
||||
state.altHeld = false;
|
||||
painting = false;
|
||||
endViewerInspect();
|
||||
endPan();
|
||||
syncEditorCursorState();
|
||||
});
|
||||
for (const [panel, canvas] of [
|
||||
["left", leftCanvas],
|
||||
["right", rightCanvas],
|
||||
]) {
|
||||
canvas.addEventListener("pointerdown", (event) => {
|
||||
beginViewerInspect(panel, event);
|
||||
});
|
||||
canvas.addEventListener("pointermove", (event) => {
|
||||
updateViewerInspect(panel, event);
|
||||
});
|
||||
canvas.addEventListener("pointerup", (event) => {
|
||||
endViewerInspect(event.pointerId);
|
||||
});
|
||||
canvas.addEventListener("pointercancel", (event) => {
|
||||
endViewerInspect(event.pointerId);
|
||||
});
|
||||
}
|
||||
editorSurface.addEventListener("mousedown", (event) => {
|
||||
if (event.button === 1) {
|
||||
event.preventDefault();
|
||||
|
|
@ -1307,6 +1437,7 @@ window.addEventListener("resize", () => {
|
|||
});
|
||||
setPaintColor(state.paintColor);
|
||||
syncEditorCursorState();
|
||||
syncViewerInspectCursor();
|
||||
zoomValue.textContent = `${state.zoom}x`;
|
||||
renderEventLog();
|
||||
void renderEverything();
|
||||
|
|
|
|||
|
|
@ -672,6 +672,8 @@ p {
|
|||
width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
cursor: zoom-in;
|
||||
touch-action: none;
|
||||
background:
|
||||
repeating-linear-gradient(0deg, rgba(255, 255, 255, 0.03) 0 1px, transparent 1px 4px),
|
||||
linear-gradient(180deg, rgba(5, 11, 13, 0.8), rgba(6, 14, 16, 0.92));
|
||||
|
|
@ -681,6 +683,10 @@ p {
|
|||
0 0 14px rgba(255, 216, 74, 0.06);
|
||||
}
|
||||
|
||||
.viz-card canvas.is-inspecting {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.metrics-panel {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
|
|
|
|||
Loading…
Reference in a new issue