eCourt-note/utils/geometry.ts
Your Name c5c6b65bd2 genesis+worker: design system Genesis ทั้งชุด + patch pdf.worker แก้ fake-space ไทย + ปิด review design/responsive/quality รอบ 2026-09-24 — (1) Genesis: ฟอนต์ General Sans self-host + DM Sans + Sarabun, AppNav 56px backdrop-blur แทน SessionBar, DotGrid หน้า login, palette/รัศมี/เงา/focus ตาม docs/DESIGN.md ครบทุกหน้า (2) postinstall scripts/patch-pdf-worker.mjs ถอด if (scaledDim) รอบ prevTransform ให้อัปเดตทุก glyph — กัน addFakeSpaces แทรก space กลางคำไทยใน PDF ที่ justify ด้วย letter-spacing + test กัน regression (tests/scripts/ + fixture thai_letterspacing.pdf, invariant #6) (3) ฟอร์มอัปโหลด + ช่องกลุ่ม typeId ทางเลือก — normDocumentGroup ที่ utils/external-file.ts เป็น single source ร่วมกับ N8 (4) token fixes: focus ring ปุ่ม pin + แถว bookmark ตามหน้า, ปุ่ม action ใน popup/composer/dialog เป็น rounded-md ทั้งคู่, selection preview เป็น tint ของ primary แทน blue-500 ดิบ, default สี shape/highlight รวมที่ utils/annotation-display, loading animate-pulse + จดข้อยกเว้น (เงา pin, font weights ของ viewer) ลง DESIGN.md (5) responsive: Export PDF max-lg:order-first ให้เห็นบน iPad, แถวบน header แถวเดียวเลื่อนได้บนจอแคบ, re-fit ความกว้างตอน toggle sidebar เฉพาะเมื่อยังไม่ซูมเอง, pin popover flip ขึ้นบนเมื่อใกล้ท้ายหน้า, toast hint ตอนสลับ tool กลางเอกสาร, ป้าย "ชนิด · ผู้ทำ" ของรายการผู้อื่นสำหรับจอสัมผัส (6) คุณภาพ: @layer ครอบ @media กันกฎ touch รั่ยไป desktop (Tailwind v3 ยก @layer ออกจาก media ทิ้ง wrapper — ยืนยันด้วย build จริง + วัดบนจอ), กฎ M5 ครอบ contenteditable กัน Safari zoom ตอนแก้ไข TEXTBOX บน iPad, COMMENT ใช้ box มาตราฐานเดียวตาม pin px จริง (render/scroll target/move-clamp/bookmark), ตัดหัวการ์ดซ้ำกับ h2 หน้ารายการ — พร้อมไฟล์ untracked ที่ postinstall/โค้ดอ้างถึงครบชุด (app/fonts/, AppNav, DotGrid, tests/) กัน fresh clone fail ที่ npm install (คำขอผู้ใช้ 2026-09-24)
Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-24 11:46:02 +07:00

347 lines
17 KiB
TypeScript
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.

import type { AnnotationData, AnnotationType, ShapeData } from "@/db/schema";
import {
clamp,
pointToPx,
rectToPx,
toPx,
type Point,
type Rect,
type Size,
} from "@/utils/coordinates";
// Geometry ของ annotation (PLAN-FEAT.md §3 F1) — pure functions ทดสอบได้
// ข้อมูลเข้า/ออกเป็นพิกัด % ตาม invariant #2 ยกเว้นฟังก์ชันที่รับ `size`
// เพื่อแปลง % → px (เช่น bbox สำหรับวาดกรอบ selection, hit-test หน่วย px)
/** ขนาดกล่อง bbox ของ comment pin (% ของความกว้างหน้า) — pin เป็นปุ่มขนาดคงที่ ไม่มีพิกัดกว้างยาวใน data
* เป็นค่าประมาณ % สำหรับ hit-test ที่ไม่รู้ขนาด render เท่านั้น — ทุกจุดที่รู้ขนาดหน้าจริง
* (กรอบ selection / scroll target / move-clamp) ใช้ commentPinBoxPx/commentPinBoxPct ตาม px จริงข้างล่าง */
export const COMMENT_PIN_BOX_PCT = 4;
/** COMMENT: ขนาด px จริงของปุ่ม pin — desktop 26px (icon 16 + p-1 รอบด้าน + border 1×2)
* และจอสัมผัส 36px (.coarse-36 ใน globals.css บังคับ min-width/height 2.25rem ให้ปุ่ม
* pin ใน PDFViewer) — pin เป็น px คงที่ไม่ scale ตาม zoom ต่างจากชนิดอื่นที่เป็น % ทั้งหมด
* (bug report 2026-09-23: กรอบไม่ตรงกล่อง + responsive audit 2026-09-24: scroll/clamp
* คิดจาก % เดิมจน pin จมใต้ header ตอนซูมออก) — แก้ปุ่ม pin หรือ .coarse-36 ต้องแก้ค่านี้ตาม */
export const COMMENT_PIN_SIZE_PX = 26;
export const COMMENT_PIN_COARSE_SIZE_PX = 36;
/** bbox px ของ COMMENT ตามขนาด render จริง — จัตุรัส px คงที่ กึ่งกลางแนวนอนที่จุด position
* และขอบล่างชนจุด (สูตรเดียวกับ -translate-x-1/2 -translate-y-full ของปุ่ม pin — ทั้งลูก
* ลอยเหนือจุดคลิก) */
export function commentPinBoxPx(d: { position: Point }, size: Size, pinPx: number): Rect {
const p = pointToPx(d.position, size);
return { x: p.x - pinPx / 2, y: p.y - pinPx, width: pinPx, height: pinPx };
}
/** bbox ของ COMMENT หน่วย % คิดจากขนาด render จริง (size = px ของหน้าที่กำลัง render) —
* ใช้กับ scroll target ของ goToAnnotation/deep link + move-clamp/bookmark ของกรอบ selection
* ให้เป็นมาตราฐานเดียวกับที่ render (responsive audit 2026-09-24 — เดิมสองมาตราฐาน:
* render ใช้ px แต่ scroll/clamp ยังใช้ COMMENT_PIN_BOX_PCT ของ annotationBBox) */
export function commentPinBoxPct(d: { position: Point }, size: Size, pinPx: number): Rect {
const wPct = (pinPx / size.width) * 100;
const hPct = (pinPx / size.height) * 100;
return { x: d.position.x - wPct / 2, y: d.position.y - hPct, width: wPct, height: hPct };
}
// TEXTBOX (F17/B2 — §5): ค่าประมาณสำหรับ bbox ของกล่องข้อความ
// render จริงกว้างสุด 50% ของหน้าแล้ว wrap อัตโนมัติ — bbox ประมาณจากตัวอักษร
// กว้าง ~0.6 × fontSize ต่อตัว บรรทัดสูง 1.5 × fontSize (ค่าประมาณพอสำหรับกรอบ selection)
export const TEXTBOX_MAX_WIDTH_PCT = 50;
export const TEXTBOX_DEFAULT_WIDTH_PCT = 30; // กล่องที่สร้างใหม่ (คลิกเดียว) กว้างเท่านี้ก่อนลากขยาย
export const TEXTBOX_CHAR_W = 0.6;
export const TEXTBOX_LINE_H = 1.5;
/** ความกว้างสูงสุดของกล่องข้อความ (% ของความกว้างหน้า) จากขอบซ้าย (position.x = มุมซ้ายบน
* เสมอ — align จัดข้อความ*ใน*กล่องอย่างเดียวแบบ Smallpdf กล่องไม่ขยับ) — บีบให้กล่อง
* ไม่ล้นขอบขวาของหน้ากระดาษ ใช้ร่วมกันทั้ง render จอ, bbox กรอบ selection และ export PDF
* ติดขอบขวาเป๊ะ (x=100) เหลือกว้างขั้นต่ำ 1% กันกล่องพังเป็นศูนย์ */
export function textboxMaxWidthPct(leftX: number): number {
return Math.min(TEXTBOX_MAX_WIDTH_PCT, Math.max(100 - clamp(leftX, 0, 100), 1));
}
// STAMP (F18/B5 — §5): กรอบตรายางกว้าง = scale% ของความกว้างหน้า สูง = 0.4 เท่าของความกว้าง
// (สูตรเดียวกันทั้ง render บนจอ, bbox ของ selection และการวาดใน export)
export const STAMP_HEIGHT_RATIO = 0.4;
type AnnotationLike = { type: AnnotationType; data: AnnotationData };
/**
* bbox ของ annotation (หน่วย px ตาม size ที่ส่งเข้ามา)
* - HIGHLIGHT: union ของ rects ทุกอัน
* - DRAW: union ของจุดทุกจุด ขยายออกด้วย strokeWidth/2 (เส้นมีความหนา)
* - COMMENT: กล่องสี่เหลี่ยมขนาด COMMENT_PIN_BOX_PCT (% ของความกว้างหน้า) รอบ position
* เคล็ดลับ: ส่ง size={width:100, height:100} เพื่อให้ผลลัพธ์เป็น "%" โดยตรง
*/
export function annotationBBox(a: AnnotationLike, size: Size): Rect {
const d = a.data;
// union ไม่ได้ discriminate โดย field ใน data เอง → แยกตาม key เฉพาะของแต่ละ variant
if ("rects" in d) {
const rects = d.rects.map((r) => rectToPx(r, size));
if (rects.length === 0) return { x: 0, y: 0, width: 0, height: 0 };
const x1 = Math.min(...rects.map((r) => r.x));
const y1 = Math.min(...rects.map((r) => r.y));
const x2 = Math.max(...rects.map((r) => r.x + r.width));
const y2 = Math.max(...rects.map((r) => r.y + r.height));
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("paths" in d) {
const pts = d.paths.map((p) => pointToPx(p, size));
const half = toPx(d.strokeWidth, size.width) / 2;
const x1 = Math.min(...pts.map((p) => p.x)) - half;
const y1 = Math.min(...pts.map((p) => p.y)) - half;
const x2 = Math.max(...pts.map((p) => p.x)) + half;
const y2 = Math.max(...pts.map((p) => p.y)) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("shape" in d) {
const half = toPx(d.strokeWidth, size.width) / 2;
if ("rect" in d) {
const r = rectToPx(d.rect, size);
return {
x: r.x - half,
y: r.y - half,
width: r.width + half * 2,
height: r.height + half * 2,
};
}
const a1 = pointToPx(d.from, size);
const b1 = pointToPx(d.to, size);
const x1 = Math.min(a1.x, b1.x) - half;
const y1 = Math.min(a1.y, b1.y) - half;
const x2 = Math.max(a1.x, b1.x) + half;
const y2 = Math.max(a1.y, b1.y) + half;
return { x: x1, y: y1, width: x2 - x1, height: y2 - y1 };
}
if ("rect" in d) {
// REDACTION — กรอบดำทึบ ไม่มีความหนาเส้นให้ขยาย
return rectToPx(d.rect, size);
}
if ("fontSize" in d) {
// TEXTBOX — position เป็นมุมซ้ายบนของกล่องเสมอ (align จัดข้อความในกล่องอย่างเดียว);
// กล่องมี width คงที่ (ถ้ามี — สไตล์ Smallpdf) ไม่มี = shrink-to-fit ประมาณจากตัวอักษร
const fontSizePx = toPx(d.fontSize, size.width);
const charW = fontSizePx * TEXTBOX_CHAR_W;
const lineH = fontSizePx * TEXTBOX_LINE_H;
// ความกว้างสูงสุดถึงขอบขวาหน้า — กล่อง width คงที่ก็ถูกบีบด้วยค่านี้เหมือน render
const capPct = textboxMaxWidthPct(d.position.x);
const wrapPx = toPx(Math.min(d.width ?? capPct, capPct), size.width);
const wrapW = Math.max(wrapPx, charW); // กันหารศูนย์เมื่อกล่องติดขอบขวาเป๊ะ
let widest = 0;
let lines = 0;
for (const raw of d.text.split(/\r?\n/)) {
widest = Math.max(widest, raw.length * charW);
lines += Math.max(1, Math.ceil((raw.length * charW) / wrapW));
}
// กล่อง width คงที่กว้างตามที่กำหนด (บีบด้วย cap) — แถวเดิม shrink-to-fit ตามบรรทัดยาวสุด
const width = (d.width !== undefined ? wrapPx : Math.min(widest, wrapPx)) || charW;
const p = pointToPx(d.position, size);
return { x: p.x, y: p.y, width, height: Math.max(lines, 1) * lineH };
}
if ("scale" in d) {
// STAMP — กรอบจริงตามสูตร render (position เป็นมุมซ้ายบน)
const w = toPx(d.scale, size.width);
const p = pointToPx(d.position, size);
return { x: p.x, y: p.y, width: w, height: w * STAMP_HEIGHT_RATIO };
}
const p = pointToPx(d.position, size);
// กล่องรอบ pin อิง % ของความกว้างหน้า — สอดคล้องทั้ง render (px) และ space %
// (size={100,100}) ไม่เกิดหน่วยเพี้ยนตอน clamp translate/scale ของ COMMENT
const box = toPx(COMMENT_PIN_BOX_PCT, size.width);
return { x: p.x - box / 2, y: p.y - box / 2, width: box, height: box };
}
/**
* เลื่อน annotation ทั้งชิ้นตาม offset (หน่วย % ของหน้า) — ใช้ตอนลากย้าย
* strokeWidth คงเดิม (การเลื่อนไม่เปลี่ยนความหนา)
* ผู้เรียกต้อง clamp dx/dy เองให้ bbox ยังอยู่ใน 0–100 ก่อนส่งเข้า action
*/
export function translateAnnotationData(
data: AnnotationData,
dxPct: number,
dyPct: number,
): AnnotationData {
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({ ...r, x: r.x + dxPct, y: r.y + dyPct })),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: p.x + dxPct, y: p.y + dyPct })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
from: { x: data.from.x + dxPct, y: data.from.y + dyPct },
to: { x: data.to.x + dxPct, y: data.to.y + dyPct },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: { ...data.rect, x: data.rect.x + dxPct, y: data.rect.y + dyPct },
};
}
return {
...data,
position: { x: data.position.x + dxPct, y: data.position.y + dyPct },
};
}
/**
* ย่อ–ขยาย annotation รอบจุด anchor (หน่วย % ของหน้า) ด้วยตัวคูณแกน x/y
* — ใช้ตอนลากมุมกรอบ (anchor = มุมตรงข้าม) ครอบ rects ทุกอัน / จุดทุกจุด / position
* strokeWidth คงเดิมตามแผน (สเปก F1 ระบุแค่ rects/points/position)
*/
export function scaleAnnotationData(
data: AnnotationData,
anchorPct: Point,
sx: number,
sy: number,
): AnnotationData {
const fx = (x: number) => anchorPct.x + (x - anchorPct.x) * sx;
const fy = (y: number) => anchorPct.y + (y - anchorPct.y) * sy;
if ("rects" in data) {
return {
...data,
rects: data.rects.map((r) => ({
x: fx(r.x),
y: fy(r.y),
width: r.width * sx,
height: r.height * sy,
})),
};
}
if ("paths" in data) {
return {
...data,
paths: data.paths.map((p) => ({ x: fx(p.x), y: fy(p.y) })),
};
}
if ("shape" in data) {
if ("rect" in data) {
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
return {
...data,
from: { x: fx(data.from.x), y: fy(data.from.y) },
to: { x: fx(data.to.x), y: fy(data.to.y) },
};
}
if ("rect" in data) {
// REDACTION
return {
...data,
rect: {
x: fx(data.rect.x),
y: fy(data.rect.y),
width: data.rect.width * sx,
height: data.rect.height * sy,
},
};
}
if ("scale" in data) {
// STAMP — ขนาดทั้งกว้าง-สูงเก็บใน scale % ตัวเดียว (สัดส่วนตายตัว
// STAMP_HEIGHT_RATIO) จึงขยายด้วยตัวคูณเดียว: caller ส่ง sx = sy = k
return {
...data,
position: { x: fx(data.position.x), y: fy(data.position.y) },
scale: data.scale * sx,
};
}
return { ...data, position: { x: fx(data.position.x), y: fy(data.position.y) } };
}
function distToSegment(p: Point, a: Point, b: Point): number {
const dx = b.x - a.x;
const dy = b.y - a.y;
const lenSq = dx * dx + dy * dy;
if (lenSq === 0) return Math.hypot(p.x - a.x, p.y - a.y);
const t = clamp(((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq, 0, 1);
return Math.hypot(p.x - (a.x + t * dx), p.y - (a.y + t * dy));
}
/**
* hit-test จุด (px) กับ polyline (% ของหน้า) — ระยะถึง segment ใด ๆ ≤ thresholdPx
* ใช้ใน F6 (eraser); threshold หน่วย px เพราะความคาดหวังของผู้ใช้อิงขนาดจอ ไม่ใช่ % ของหน้า
*/
export function hitTestPath(
pointsPct: Point[],
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
if (pointsPct.length === 0) return false;
const pts = pointsPct.map((p) => pointToPx(p, size));
const pt = pointToPx(pointPct, size);
if (pts.length === 1) return Math.hypot(pt.x - pts[0].x, pt.y - pts[0].y) <= thresholdPx;
for (let i = 0; i < pts.length - 1; i++) {
if (distToSegment(pt, pts[i], pts[i + 1]) <= thresholdPx) return true;
}
return false;
}
/** ระยะจากจุดถึงเส้นขอบสี่เหลี่ยม (px) — อยู่ใน "พื้นที่" กรอบจะได้ระยะถึงขอบ ไม่ใช่ 0 */
function distToRectOutline(p: Point, r: Rect): number {
const inside =
p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height;
if (inside) {
const dx = Math.min(p.x - r.x, r.x + r.width - p.x);
const dy = Math.min(p.y - r.y, r.y + r.height - p.y);
return Math.min(dx, dy);
}
const cx = Math.max(r.x, Math.min(p.x, r.x + r.width));
const cy = Math.max(r.y, Math.min(p.y, r.y + r.height));
return Math.hypot(p.x - cx, p.y - cy);
}
/** ประมาณระยะจากจุดถึงเส้นรอบวงรี (px) — scale-space: |r−1|×min(rx,ry) พอสำหรับ eraser */
function distToEllipseOutline(p: Point, r: Rect): number {
const cx = r.x + r.width / 2;
const cy = r.y + r.height / 2;
const rx = r.width / 2;
const ry = r.height / 2;
if (rx === 0 || ry === 0) return Math.hypot(p.x - cx, p.y - cy);
const ratio = Math.hypot((p.x - cx) / rx, (p.y - cy) / ry);
return Math.abs(ratio - 1) * Math.min(rx, ry);
}
/**
* hit-test จุดกับรูปทรง (F6 eraser) — เฉพาะ "เส้น stroke/ขอบ" เท่านั้น
* ผ่านพื้นที่ตรงกลางรูปทรงไม่โดน (PLAN-FEAT.md §5)
*/
export function hitTestShape(
data: ShapeData,
pointPct: Point,
thresholdPx: number,
size: Size,
): boolean {
const p = pointToPx(pointPct, size);
if ("rect" in data) {
const r = rectToPx(data.rect, size);
if (data.shape === "ellipse") return distToEllipseOutline(p, r) <= thresholdPx;
return distToRectOutline(p, r) <= thresholdPx;
}
const a = pointToPx(data.from, size);
const b = pointToPx(data.to, size);
return distToSegment(p, a, b) <= thresholdPx;
}