dasumae-frame / src /layout.js
NagaYu's picture
Upload layout.js
fd3b6ac verified
Raw History Blame Contribute Delete
10.8 kB
// DasuMae Frame β€” layout engine (pure geometry, no DOM, no dependencies).
//
// Single source of truth for where the screenshot(s) are drawn on the export
// canvas. The live preview and the export renderer both call the same
// functions here so what you see is exactly what you get.
//
// Model
// -----
// 1. The canvas is canvasW Γ— canvasH device pixels (an export preset size,
// e.g. 1600Γ—900, or the Original fit size).
// 2. Padding insets the canvas on all four sides, leaving the *content rect*
// β€” the area the image(s) are allowed to occupy. Padding per side is
// pad = (paddingPct / 100) * (min(canvasW, canvasH) / 2)
// i.e. paddingPct is a fraction of the shorter side split across the two
// opposite edges. It is clamped so the content rect never collapses below
// MIN_CONTENT px on either axis.
// 3. The content rect is split into *cells*: 1 cell in single mode (today's
// behaviour, byte-for-byte unchanged), or 2 cells in 2-up ("duo") mode β€”
// columns (left/right) or rows (top/bottom) β€” separated by a gap (px at
// export scale). The gap lives only *between* cells; the padding still
// sits outside, so the outer edges are never pushed in by the gap and the
// gap is never doubled at the canvas edge. See cellRects().
// 4. Each cell runs today's per-image math independently (placeInRect): the
// image is fitted into the cell rect with "contain" semantics
// (baseScale = min(cellW/imageW, cellH/imageH)), then multiplied by the
// user scale. drawnW/H = imageW/H * baseScale * scale.
// 5. The image is centred in its cell, then shifted by the drag offset
// (offsetX, offsetY) in canvas pixels. The offset is clamped to that
// cell (see below) and the *clamped* value is returned.
//
// Offset clamp rule (documented, one rule for both regimes)
// ---------------------------------------------------------
// On each axis independently let
// limit = |contentSize - drawnSize| / 2
// and clamp the offset to [-limit, +limit].
// β€’ Zoomed in (drawnSize >= contentSize): limit = (drawn - content)/2.
// The image always fully covers the content rect; an edge can travel up
// to the matching content edge but never past it, so dragging never
// exposes a background gap *inside* the content area.
// β€’ Fitted (drawnSize < contentSize): limit = (content - drawn)/2.
// There is intended padding slack; the image may slide until one of its
// edges meets a content edge, but no further β€” so it can be repositioned
// without being flung out and leaving an absurd gap.
// When drawnSize == contentSize the limit is 0 (no travel), which is correct.
export const MIN_CONTENT = 1; // px β€” content rect never smaller than this
/** Clamp a number to [lo, hi]. */
export function clamp(v, lo, hi) {
if (hi < lo) return lo;
return v < lo ? lo : v > hi ? hi : v;
}
/**
* Content rect (the padded area the image lives in) for a canvas.
* @returns {{x:number,y:number,w:number,h:number,pad:number}}
*/
export function contentRect(canvasW, canvasH, paddingPct) {
const W = Math.max(0, canvasW);
const H = Math.max(0, canvasH);
const pct = clamp(paddingPct, 0, 100);
let pad = (pct / 100) * (Math.min(W, H) / 2);
// Keep at least MIN_CONTENT on each axis.
const maxPad = Math.max(0, (Math.min(W, H) - MIN_CONTENT) / 2);
pad = clamp(pad, 0, maxPad);
return { x: pad, y: pad, w: W - 2 * pad, h: H - 2 * pad, pad };
}
/** "contain" fit scale of an image into a box. */
export function fitScale(boxW, boxH, imageW, imageH) {
if (imageW <= 0 || imageH <= 0 || boxW <= 0 || boxH <= 0) return 0;
return Math.min(boxW / imageW, boxH / imageH);
}
/** Per-axis max offset magnitude for the clamp rule above. */
export function offsetLimit(contentSize, drawnSize) {
return Math.abs(contentSize - drawnSize) / 2;
}
/**
* Place one image inside an arbitrary rectangle ("contain" fit + user scale +
* clamped drag offset). This is today's per-image math, factored out so a
* single cell (single mode) and each of two cells (2-up) share exactly the
* same geometry β€” the clamp is always relative to the rect it is given.
*
* @param {{x:number,y:number,w:number,h:number}} rect the cell the image lives in
* @param {number} imageW source image width (px)
* @param {number} imageH source image height (px)
* @param {number} [scale=1] user scale multiplier (>0), applied on top of fit
* @param {number} [offsetX=0] drag offset X (px, before clamp)
* @param {number} [offsetY=0] drag offset Y (px, before clamp)
* @returns {{
* image:{x:number,y:number,w:number,h:number},
* baseScale:number, effectiveScale:number,
* offset:{x:number,y:number}, limit:{x:number,y:number}
* }}
*/
export function placeInRect(rect, imageW, imageH, scale, offsetX, offsetY) {
const iW = Math.max(0, imageW || 0);
const iH = Math.max(0, imageH || 0);
const s = scale == null ? 1 : Math.max(0, scale);
const oX = offsetX || 0;
const oY = offsetY || 0;
// Degenerate image: nothing to draw (anchored at the cell's top-left).
if (iW === 0 || iH === 0) {
return {
image: { x: rect.x, y: rect.y, w: 0, h: 0 },
baseScale: 0,
effectiveScale: 0,
offset: { x: 0, y: 0 },
limit: { x: 0, y: 0 },
};
}
const baseScale = fitScale(rect.w, rect.h, iW, iH);
const effectiveScale = baseScale * s;
const drawnW = iW * effectiveScale;
const drawnH = iH * effectiveScale;
const limitX = offsetLimit(rect.w, drawnW);
const limitY = offsetLimit(rect.h, drawnH);
const clampedX = clamp(oX, -limitX, limitX);
const clampedY = clamp(oY, -limitY, limitY);
// Centre of the cell.
const cx = rect.x + rect.w / 2;
const cy = rect.y + rect.h / 2;
return {
image: {
x: cx - drawnW / 2 + clampedX,
y: cy - drawnH / 2 + clampedY,
w: drawnW,
h: drawnH,
},
baseScale,
effectiveScale,
offset: { x: clampedX, y: clampedY },
limit: { x: limitX, y: limitY },
};
}
/**
* Split a content rect into cells.
* β€’ single β†’ one cell equal to the whole content rect.
* β€’ duo / columns β†’ left + right cells, each (contentW βˆ’ gap)/2 wide,
* full content height, with `gap` px between them.
* β€’ duo / rows β†’ top + bottom cells, each (contentH βˆ’ gap)/2 tall,
* full content width, with `gap` px between them.
* The gap sits ONLY between the two cells; the padding stays outside, so the
* outer edges are the content-rect edges (never inset by the gap) and the gap
* is never doubled at the canvas edge. The gap is clamped so neither cell
* collapses below MIN_CONTENT on the split axis.
*
* @returns {Array<{x:number,y:number,w:number,h:number}>}
*/
export function cellRects(content, mode = 'single', orientation = 'columns', gap = 0) {
if (mode !== 'duo') {
return [{ x: content.x, y: content.y, w: content.w, h: content.h }];
}
const g0 = Math.max(0, gap || 0);
if (orientation === 'rows') {
const g = clamp(g0, 0, Math.max(0, content.h - 2 * MIN_CONTENT));
const cellH = (content.h - g) / 2;
return [
{ x: content.x, y: content.y, w: content.w, h: cellH },
{ x: content.x, y: content.y + cellH + g, w: content.w, h: cellH },
];
}
const g = clamp(g0, 0, Math.max(0, content.w - 2 * MIN_CONTENT));
const cellW = (content.w - g) / 2;
return [
{ x: content.x, y: content.y, w: cellW, h: content.h },
{ x: content.x + cellW + g, y: content.y, w: cellW, h: content.h },
];
}
/**
* Compute the drawn image rectangle on the export canvas (single image).
* Thin wrapper over contentRect() + placeInRect() β€” its result is byte-for-byte
* identical to the original single-image engine, so Phase 1 is unchanged.
*
* @param {object} p
* @param {number} p.canvasW export/preview canvas width (px)
* @param {number} p.canvasH export/preview canvas height (px)
* @param {number} p.imageW source image width (px)
* @param {number} p.imageH source image height (px)
* @param {number} [p.paddingPct=0] padding as % of shorter side (0..100)
* @param {number} [p.scale=1] user scale multiplier (>0), applied on top of fit
* @param {number} [p.offsetX=0] drag offset X (px, before clamp)
* @param {number} [p.offsetY=0] drag offset Y (px, before clamp)
* @returns {{
* content:{x:number,y:number,w:number,h:number,pad:number},
* image:{x:number,y:number,w:number,h:number},
* baseScale:number,
* effectiveScale:number,
* offset:{x:number,y:number},
* limit:{x:number,y:number}
* }}
*/
export function computeLayout(p) {
const canvasW = Math.max(0, p.canvasW || 0);
const canvasH = Math.max(0, p.canvasH || 0);
const paddingPct = p.paddingPct == null ? 0 : p.paddingPct;
const content = contentRect(canvasW, canvasH, paddingPct);
const placed = placeInRect(content, p.imageW, p.imageH, p.scale, p.offsetX, p.offsetY);
return { content, ...placed };
}
/**
* Compute the full composite layout: content rect β†’ cells β†’ per-cell image
* placement. Single mode returns exactly one cell (identical to computeLayout).
*
* @param {object} p
* @param {number} p.canvasW
* @param {number} p.canvasH
* @param {number} [p.paddingPct=0]
* @param {'single'|'duo'} [p.mode='single']
* @param {'columns'|'rows'} [p.orientation='columns'] (duo only)
* @param {number} [p.gap=0] px at export scale, between the two cells (duo only)
* @param {Array<{imageW:number,imageH:number,scale?:number,offsetX?:number,offsetY?:number}>} [p.images=[]]
* @returns {{
* content:{x:number,y:number,w:number,h:number,pad:number},
* mode:'single'|'duo', orientation:'columns'|'rows', gap:number,
* cells: Array<{
* cell:{x:number,y:number,w:number,h:number},
* image:{x:number,y:number,w:number,h:number},
* baseScale:number, effectiveScale:number,
* offset:{x:number,y:number}, limit:{x:number,y:number}
* }>
* }}
*/
export function computeComposite(p) {
const canvasW = Math.max(0, p.canvasW || 0);
const canvasH = Math.max(0, p.canvasH || 0);
const paddingPct = p.paddingPct == null ? 0 : p.paddingPct;
const content = contentRect(canvasW, canvasH, paddingPct);
const mode = p.mode === 'duo' ? 'duo' : 'single';
const orientation = p.orientation === 'rows' ? 'rows' : 'columns';
const gap = mode === 'duo' ? Math.max(0, p.gap || 0) : 0;
const rects = cellRects(content, mode, orientation, gap);
const images = p.images || [];
const cells = rects.map((rect, i) => {
const im = images[i] || {};
const placed = placeInRect(rect, im.imageW, im.imageH, im.scale, im.offsetX, im.offsetY);
return { cell: rect, ...placed };
});
return { content, mode, orientation, gap, cells };
}