fix(report): draw North Indian charts from fences after skipHtml dropped SVG
Longform report pages kept the D1/D9/Moon and varga headings but skipHtml stripped the engine's inline SVG. Emit a jyotish-chart JSON fence beside each SVG and render a diamond chart on the reader without relaxing HTML sanitization. BUG-607. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
/** 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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { downloadMarkdownReport } from "./consultation-report-export";
|
||||
import { PERSONAL_REPORT_LEGACY_PLACEHOLDER } from "./personal-report-longform-copy";
|
||||
import { personalReportMarkdownFilename } from "./personal-report-longform-outline";
|
||||
import { stripReportChartBlocks } from "./report-chart-block";
|
||||
|
||||
export async function requestPersonalReportLongformAppendix(reportId: string): Promise<string> {
|
||||
const response = await fetch(`/api/reports/${encodeURIComponent(reportId)}/professional-reference`, {
|
||||
@@ -30,5 +31,8 @@ export async function downloadPersonalReportLongformAppendix(
|
||||
const markdown = options.markdown?.trim()
|
||||
? options.markdown
|
||||
: await requestPersonalReportLongformAppendix(reportId);
|
||||
downloadMarkdownReport(personalReportMarkdownFilename(options.reportDate), markdown);
|
||||
downloadMarkdownReport(
|
||||
personalReportMarkdownFilename(options.reportDate),
|
||||
stripReportChartBlocks(markdown),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import { z } from "zod";
|
||||
|
||||
const SIGNS = [
|
||||
"Aries",
|
||||
"Taurus",
|
||||
"Gemini",
|
||||
"Cancer",
|
||||
"Leo",
|
||||
"Virgo",
|
||||
"Libra",
|
||||
"Scorpio",
|
||||
"Sagittarius",
|
||||
"Capricorn",
|
||||
"Aquarius",
|
||||
"Pisces",
|
||||
] as const;
|
||||
|
||||
const PLANETS = [
|
||||
"Sun",
|
||||
"Moon",
|
||||
"Mars",
|
||||
"Mercury",
|
||||
"Jupiter",
|
||||
"Venus",
|
||||
"Saturn",
|
||||
"Rahu",
|
||||
"Ketu",
|
||||
] as const;
|
||||
|
||||
const PLANET_LABEL: Record<(typeof PLANETS)[number], string> = {
|
||||
Sun: "日",
|
||||
Moon: "月",
|
||||
Mars: "火",
|
||||
Mercury: "水",
|
||||
Jupiter: "木",
|
||||
Venus: "金",
|
||||
Saturn: "土",
|
||||
Rahu: "罗",
|
||||
Ketu: "计",
|
||||
};
|
||||
|
||||
const SIGN_NUMBER: Record<string, number> = {
|
||||
Aries: 1,
|
||||
Taurus: 2,
|
||||
Gemini: 3,
|
||||
Cancer: 4,
|
||||
Leo: 5,
|
||||
Virgo: 6,
|
||||
Libra: 7,
|
||||
Scorpio: 8,
|
||||
Sagittarius: 9,
|
||||
Capricorn: 10,
|
||||
Aquarius: 11,
|
||||
Pisces: 12,
|
||||
白羊: 1,
|
||||
金牛: 2,
|
||||
双子: 3,
|
||||
巨蟹: 4,
|
||||
狮子: 5,
|
||||
处女: 6,
|
||||
天秤: 7,
|
||||
天蝎: 8,
|
||||
射手: 9,
|
||||
摩羯: 10,
|
||||
水瓶: 11,
|
||||
双鱼: 12,
|
||||
白羊座: 1,
|
||||
金牛座: 2,
|
||||
双子座: 3,
|
||||
巨蟹座: 4,
|
||||
狮子座: 5,
|
||||
处女座: 6,
|
||||
天秤座: 7,
|
||||
天蝎座: 8,
|
||||
射手座: 9,
|
||||
摩羯座: 10,
|
||||
水瓶座: 11,
|
||||
双鱼座: 12,
|
||||
};
|
||||
|
||||
const FENCE_RE = /(?:\n[ \t]*)?```jyotish-chart\n[\s\S]*?```(?:\n[ \t]*)?/g;
|
||||
|
||||
export const reportChartBlockSchema = z.strictObject({
|
||||
version: z.literal(1),
|
||||
id: z.string().regex(/^(D\d{1,3}|MOON)$/),
|
||||
title: z.string().max(120),
|
||||
layout: z.literal("north"),
|
||||
ascendant: z.strictObject({
|
||||
sign: z.enum(SIGNS),
|
||||
degree: z.number().gte(0).lt(30),
|
||||
}),
|
||||
planets: z.array(z.strictObject({
|
||||
name: z.enum(PLANETS),
|
||||
sign: z.enum(SIGNS),
|
||||
degree: z.number().gte(0).lt(30),
|
||||
retrograde: z.boolean(),
|
||||
})).max(9),
|
||||
});
|
||||
|
||||
export type ReportChartBlock = z.infer<typeof reportChartBlockSchema>;
|
||||
|
||||
export type NorthIndianChartModel = {
|
||||
houses: { houseNumber: number; sign: string; occupants: string[] }[];
|
||||
planets?: { name: string; retrograde: boolean }[];
|
||||
};
|
||||
|
||||
export function parseReportChartBlock(source: string): ReportChartBlock | null {
|
||||
try {
|
||||
return reportChartBlockSchema.parse(JSON.parse(source));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function integerDegree(degree: number): number {
|
||||
const value = Math.floor(degree);
|
||||
if (value >= 30) return 29;
|
||||
if (value < 0) return 0;
|
||||
return value;
|
||||
}
|
||||
|
||||
export function planetDisplayLabel(
|
||||
name: (typeof PLANETS)[number],
|
||||
degree: number,
|
||||
retrograde: boolean,
|
||||
): string {
|
||||
const body = PLANET_LABEL[name];
|
||||
const suffix = retrograde ? "逆" : "";
|
||||
return `${body}${suffix} ${integerDegree(degree)}°`;
|
||||
}
|
||||
|
||||
export function signOrdinal(sign: string): number | null {
|
||||
return SIGN_NUMBER[sign] ?? null;
|
||||
}
|
||||
|
||||
export function chartAriaLabel(id: ReportChartBlock["id"]): string {
|
||||
return id === "MOON" ? "月亮参考盘" : `${id} 北印度盘`;
|
||||
}
|
||||
|
||||
export function toNorthIndianChart(block: ReportChartBlock): NorthIndianChartModel {
|
||||
const ascIndex = SIGNS.indexOf(block.ascendant.sign);
|
||||
const occupantsByHouse: string[][] = Array.from({ length: 12 }, () => []);
|
||||
for (const planet of block.planets) {
|
||||
const signIndex = SIGNS.indexOf(planet.sign);
|
||||
const houseNumber = ((signIndex - ascIndex + 12) % 12) + 1;
|
||||
occupantsByHouse[houseNumber - 1].push(
|
||||
planetDisplayLabel(planet.name, planet.degree, planet.retrograde),
|
||||
);
|
||||
}
|
||||
return {
|
||||
houses: Array.from({ length: 12 }, (_, index) => ({
|
||||
houseNumber: index + 1,
|
||||
sign: SIGNS[(ascIndex + index + 12) % 12],
|
||||
occupants: occupantsByHouse[index],
|
||||
})),
|
||||
planets: block.planets.map((planet) => ({
|
||||
name: PLANET_LABEL[planet.name],
|
||||
retrograde: planet.retrograde,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
export function stripReportChartBlocks(markdown: string): string {
|
||||
const stripped = markdown.replace(FENCE_RE, "\n\n");
|
||||
return stripped.replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
Reference in New Issue
Block a user