Files
Jyotisha/frontend/src/lib/north-indian-chart-geometry.ts
T
Jesse_ChenandClaude Fable 5.1 5a1dcbd2a1
Independent Staging Quality Gate / validate (push) Failing after 8m58s
Independent Staging Quality Gate / publish (push) Skipped
feat(chart): 星盘改用行星符号与颜色,度数进行星表
北印星盘(星盘页 / 校正右栏 / 我的报告共用 VedicChartSvg)宫内不再画
「水 19°」「罗逆 29°」这类显示文本——一宫三颗星就叠三行、字号压到 8。
改成九个彩色符号横排,每行最多 3 个;逆行是符号下方一道同色横线,
第 1 宫加 As 小标,盘下方两行图例。度数、星宿、顺逆下沉到星盘页的
行星表,列从四列扩到八列,首行是上升,度数精确到分。

- 符号字体:vendored 的 9 字形 Noto Sans Symbols 子集(OFL,1.5 KB),
  经 next/font/local 挂成 --font-planet-glyphs;♀ ♂ 带 U+FE0E 防 iOS
  画成表情。构建镜像不能联网,所以必须自带,与 Inter 同一条理由。
- 颜色:--color-planet-* 九个 token,:root 与两个深色块各一份。只用于
  认星不表吉凶,罗计共用中性灰。挂 is-* 修饰类而非行内 style——报告
  文档的标记合同禁止渲染结果出现 style=,星盘不做例外。
- 数据来源:toNorthIndianChart() 加结构化 occupantGlyphs(星盘页与
  markdown fence 走这条);校正与报告两条路线的模型只有引擎 / golden
  显示文本,按首字解析,解析由测试锁住。
- 星宿沿用引擎返回名:仓库无中文宿名表,印度 27 宿与中国二十八宿不是
  同一套,不自造对照。

tsc 0 错,lint 0 error,npm test 3471→3484 条、36 红与基线逐条相同,
/ 仍 Static,首屏 JS gzip +0.29%。

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JUei7K13cYxLHE3Axe4A45
2026-09-18 00:39:13 +00:00

133 lines
5.0 KiB
TypeScript

/** North Indian (diamond) house polygons for a square of side S. */
export const CHART_SIZE = 400;
export type Point = Readonly<{ x: number; y: number }>;
const S = CHART_SIZE;
const H = S / 2;
const Q = S / 4;
/** House 1 is the top diamond; numbers run clockwise from there. */
export const HOUSE_POLYGONS: Readonly<Record<number, readonly Point[]>> = {
1: [{ x: H, y: 0 }, { x: 3 * Q, y: Q }, { x: H, y: H }, { x: Q, y: Q }],
2: [{ x: 0, y: 0 }, { x: H, y: 0 }, { x: Q, y: Q }],
3: [{ x: 0, y: 0 }, { x: Q, y: Q }, { x: 0, y: H }],
4: [{ x: 0, y: H }, { x: Q, y: Q }, { x: H, y: H }, { x: Q, y: 3 * Q }],
5: [{ x: 0, y: H }, { x: Q, y: 3 * Q }, { x: 0, y: S }],
6: [{ x: 0, y: S }, { x: Q, y: 3 * Q }, { x: H, y: S }],
7: [{ x: H, y: S }, { x: Q, y: 3 * Q }, { x: H, y: H }, { x: 3 * Q, y: 3 * Q }],
8: [{ x: H, y: S }, { x: 3 * Q, y: 3 * Q }, { x: S, y: S }],
9: [{ x: S, y: S }, { x: 3 * Q, y: 3 * Q }, { x: S, y: H }],
10: [{ x: S, y: H }, { x: 3 * Q, y: 3 * Q }, { x: H, y: H }, { x: 3 * Q, y: Q }],
11: [{ x: S, y: H }, { x: 3 * Q, y: Q }, { x: S, y: 0 }],
12: [{ x: S, y: 0 }, { x: 3 * Q, y: Q }, { x: H, y: 0 }],
};
export const HOUSE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12] as const;
const CENTER: Point = { x: H, y: H };
export function polygonPoints(points: readonly Point[]): string {
return points.map((point) => `${point.x},${point.y}`).join(" ");
}
export function polygonCentroid(points: readonly Point[]): Point {
const areaTimes2 = points.reduce((sum, point, index) => {
const next = points[(index + 1) % points.length];
return sum + (point.x * next.y - next.x * point.y);
}, 0);
if (Math.abs(areaTimes2) < 1e-9) {
const n = points.length || 1;
return {
x: points.reduce((sum, point) => sum + point.x, 0) / n,
y: points.reduce((sum, point) => sum + point.y, 0) / n,
};
}
const cx = points.reduce((sum, point, index) => {
const next = points[(index + 1) % points.length];
return sum + (point.x + next.x) * (point.x * next.y - next.x * point.y);
}, 0) / (3 * areaTimes2);
const cy = points.reduce((sum, point, index) => {
const next = points[(index + 1) % points.length];
return sum + (point.y + next.y) * (point.x * next.y - next.x * point.y);
}, 0) / (3 * areaTimes2);
return { x: cx, y: cy };
}
export function polygonArea(points: readonly Point[]): number {
const sum = points.reduce((total, point, index) => {
const next = points[(index + 1) % points.length];
return total + (point.x * next.y - next.x * point.y);
}, 0);
return Math.abs(sum) / 2;
}
export function pointInPolygon(point: Point, polygon: readonly Point[]): boolean {
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i, i += 1) {
const a = polygon[i];
const b = polygon[j];
const intersect = (a.y > point.y) !== (b.y > point.y)
&& point.x < ((b.x - a.x) * (point.y - a.y)) / ((b.y - a.y) || Number.EPSILON) + a.x;
if (intersect) inside = !inside;
}
return inside;
}
function distance(a: Point, b: Point): number {
return Math.hypot(a.x - b.x, a.y - b.y);
}
function moveToward(from: Point, to: Point, distancePx: number): Point {
const dx = to.x - from.x;
const dy = to.y - from.y;
const length = Math.hypot(dx, dy) || 1;
return { x: from.x + (dx / length) * distancePx, y: from.y + (dy / length) * distancePx };
}
function rightAngleVertex(points: readonly Point[]): Point {
for (let index = 0; index < points.length; index += 1) {
const prev = points[(index + points.length - 1) % points.length];
const vertex = points[index];
const next = points[(index + 1) % points.length];
const d1x = prev.x - vertex.x;
const d1y = prev.y - vertex.y;
const d2x = next.x - vertex.x;
const d2y = next.y - vertex.y;
if (Math.abs(d1x * d2x + d1y * d2y) < 1e-6) return vertex;
}
return points[0];
}
function innerVertex(points: readonly Point[]): Point {
return points.reduce((best, point) => (distance(point, CENTER) < distance(best, CENTER) ? point : best));
}
/**
* Where a house's planet symbols sit. The raw centroid crowds the sign number
* in the triangular houses, so the group is nudged away from the vertex the
* number hangs on — the centre for the four diamonds, the right angle for the
* eight triangles.
*/
export function occupantAnchor(houseNumber: number): Point {
const polygon = HOUSE_POLYGONS[houseNumber];
const centroid = polygonCentroid(polygon);
const isDiamond = polygon.length === 4;
const anchorVertex = isDiamond ? innerVertex(polygon) : rightAngleVertex(polygon);
const push = isDiamond ? 0.05 : 0.18;
return {
x: centroid.x + (centroid.x - anchorVertex.x) * push,
y: centroid.y + (centroid.y - anchorVertex.y) * push,
};
}
export function signNumberAnchor(houseNumber: number): Point {
const polygon = HOUSE_POLYGONS[houseNumber];
const centroid = polygonCentroid(polygon);
if (polygon.length === 4) {
return moveToward(innerVertex(polygon), centroid, 12);
}
return moveToward(rightAngleVertex(polygon), centroid, 14);
}