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:
@@ -4,13 +4,13 @@ import { Check, Copy } from "lucide-react";
|
||||
import { isValidElement, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
||||
/** The fenced language, from the `language-xxx` class react-markdown puts on <code>. */
|
||||
function fencedLanguage(node: ReactNode): string {
|
||||
export function fencedLanguage(node: ReactNode): string {
|
||||
if (!isValidElement<{ className?: string }>(node)) return "";
|
||||
return /language-([\w+-]+)/.exec(node.props.className ?? "")?.[1] ?? "";
|
||||
}
|
||||
|
||||
/** The raw source inside the fence, for the copy button. */
|
||||
function fencedSource(node: ReactNode): string {
|
||||
export function fencedSource(node: ReactNode): string {
|
||||
if (typeof node === "string") return node;
|
||||
if (Array.isArray(node)) return node.map(fencedSource).join("");
|
||||
if (isValidElement<{ children?: ReactNode }>(node)) return fencedSource(node.props.children);
|
||||
|
||||
@@ -4,6 +4,13 @@ import { useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkGfm from "remark-gfm";
|
||||
|
||||
import { fencedLanguage, fencedSource } from "@/components/markdown-code-block";
|
||||
import {
|
||||
chartAriaLabel,
|
||||
parseReportChartBlock,
|
||||
toNorthIndianChart,
|
||||
} from "@/lib/report-chart-block";
|
||||
import { NorthIndianChartSvg } from "@/components/personal-report/vedic-chart-svg";
|
||||
import {
|
||||
buildLongformOutline,
|
||||
type LongformHeading,
|
||||
@@ -49,6 +56,32 @@ function markdownComponents(headings: readonly LongformHeading[]): Components {
|
||||
),
|
||||
h2: ({ children }) => <h2 id={nextId(2, children)}>{children}</h2>,
|
||||
h3: ({ children }) => <h3 id={nextId(3, children)}>{children}</h3>,
|
||||
h4: ({ children }) => {
|
||||
const title = flattenText(children).trim();
|
||||
const isChart = /^(?:D\d{1,3}\b|Moon Chart)/.test(title);
|
||||
return (
|
||||
<h4 className={isChart ? "personal-report-chart-heading" : undefined}>
|
||||
{children}
|
||||
</h4>
|
||||
);
|
||||
},
|
||||
pre: ({ children, ...props }) => {
|
||||
if (fencedLanguage(children) !== "jyotish-chart") {
|
||||
return <pre {...props}>{children}</pre>;
|
||||
}
|
||||
const block = parseReportChartBlock(fencedSource(children).trim());
|
||||
if (!block) {
|
||||
return <p className="personal-report-chart-invalid">图盘数据无效</p>;
|
||||
}
|
||||
return (
|
||||
<figure className="personal-report-chart-figure">
|
||||
<NorthIndianChartSvg
|
||||
ariaLabel={chartAriaLabel(block.id)}
|
||||
chart={toNorthIndianChart(block)}
|
||||
/>
|
||||
</figure>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1,101 +1,83 @@
|
||||
"use client";
|
||||
|
||||
/**
|
||||
* Pure React SVG North Indian (Rasi) chart for the personal report.
|
||||
* Pure React SVG North Indian (Rasi) chart.
|
||||
*
|
||||
* - All text comes from the schema-validated report document (house sign,
|
||||
* occupants, retrograde markers from the real planets array).
|
||||
* - No canvas, no base64, no remote images/fonts, no inner-HTML injection APIs.
|
||||
* - Uses a viewBox so print output stays sharp at any size.
|
||||
* - Fixed house cells follow the standard North Indian layout:
|
||||
*
|
||||
* 12 | 11 | 10 | 9
|
||||
* 1 | | | 8
|
||||
* 2 | | | 7
|
||||
* 3 | 4 | 5 | 6
|
||||
* House geometry is the standard diamond chart: two diagonals plus the
|
||||
* inner rhombus from the four midpoints. Sign numbers are rasi ordinals
|
||||
* (1 = Aries … 12 = Pisces). Occupant labels are already display text.
|
||||
*/
|
||||
|
||||
import { useId } from "react";
|
||||
|
||||
import type { ChartV1 } from "@/lib/personal-report-contract";
|
||||
import {
|
||||
CHART_SIZE,
|
||||
HOUSE_NUMBERS,
|
||||
HOUSE_POLYGONS,
|
||||
polygonCentroid,
|
||||
polygonPoints,
|
||||
signNumberAnchor,
|
||||
} from "@/lib/north-indian-chart-geometry";
|
||||
import { signOrdinal, type NorthIndianChartModel } from "@/lib/report-chart-block";
|
||||
|
||||
type HouseV1 = ChartV1["houses"][number];
|
||||
export const CHART_VIEWBOX_WIDTH = CHART_SIZE;
|
||||
export const CHART_VIEWBOX_HEIGHT = CHART_SIZE;
|
||||
|
||||
export const CHART_VIEWBOX_WIDTH = 400;
|
||||
export const CHART_VIEWBOX_HEIGHT = 400;
|
||||
const CELL = 100;
|
||||
|
||||
const HOUSE_CELLS: Record<number, { x: number; y: number }> = {
|
||||
1: { x: 0, y: CELL },
|
||||
2: { x: 0, y: CELL * 2 },
|
||||
3: { x: 0, y: CELL * 3 },
|
||||
4: { x: CELL, y: CELL * 3 },
|
||||
5: { x: CELL * 2, y: CELL * 3 },
|
||||
6: { x: CELL * 3, y: CELL * 3 },
|
||||
7: { x: CELL * 3, y: CELL * 2 },
|
||||
8: { x: CELL * 3, y: CELL },
|
||||
9: { x: CELL * 3, y: 0 },
|
||||
10: { x: CELL * 2, y: 0 },
|
||||
11: { x: CELL, y: 0 },
|
||||
12: { x: 0, y: 0 },
|
||||
};
|
||||
|
||||
const HOUSE_NUMBERS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12];
|
||||
export type VedicChartModel = NorthIndianChartModel;
|
||||
|
||||
interface VedicChartSvgProps {
|
||||
chart: ChartV1;
|
||||
chart: NorthIndianChartModel;
|
||||
ariaLabel: string;
|
||||
}
|
||||
|
||||
function buildRetrogradeSet(chart: ChartV1): Set<string> {
|
||||
function buildRetrogradeSet(chart: NorthIndianChartModel): Set<string> {
|
||||
const retrograde = new Set<string>();
|
||||
for (const planet of chart.planets ?? []) {
|
||||
if (planet.retrograde) {
|
||||
retrograde.add(planet.name);
|
||||
}
|
||||
if (planet.retrograde) retrograde.add(planet.name);
|
||||
}
|
||||
return retrograde;
|
||||
}
|
||||
|
||||
/** Combine house occupants with real retrograde markers from chart.planets. */
|
||||
function occupantLines(house: HouseV1, chart: ChartV1): string[] {
|
||||
function occupantLines(house: NorthIndianChartModel["houses"][number], chart: NorthIndianChartModel): string[] {
|
||||
const retrograde = buildRetrogradeSet(chart);
|
||||
return house.occupants.map((name) => (retrograde.has(name) ? `${name}逆` : name));
|
||||
return house.occupants.map((name) => {
|
||||
if (name.includes("逆") || name.includes("°")) return name;
|
||||
return retrograde.has(name) ? `${name}逆` : name;
|
||||
});
|
||||
}
|
||||
|
||||
const MAX_RENDERED_OCCUPANTS = 8;
|
||||
|
||||
function truncateSvgLabel(value: string, maxCharacters: number): string {
|
||||
const characters = Array.from(value);
|
||||
if (characters.length <= maxCharacters) return value;
|
||||
return `${characters.slice(0, Math.max(1, maxCharacters - 1)).join("")}…`;
|
||||
}
|
||||
const MAX_RENDERED_OCCUPANTS = 6;
|
||||
|
||||
function visibleOccupantLines(lines: string[]): string[] {
|
||||
if (lines.length <= MAX_RENDERED_OCCUPANTS) return lines;
|
||||
return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)} 项`];
|
||||
return [...lines.slice(0, MAX_RENDERED_OCCUPANTS - 1), `+${lines.length - (MAX_RENDERED_OCCUPANTS - 1)}`];
|
||||
}
|
||||
|
||||
function PlanetList({ lines }: { lines: string[] }) {
|
||||
function OccupantStack({
|
||||
lines,
|
||||
origin,
|
||||
}: {
|
||||
lines: string[];
|
||||
origin: { x: number; y: number };
|
||||
}) {
|
||||
const visible = visibleOccupantLines(lines);
|
||||
const twoColumns = visible.length > 4;
|
||||
const fontSize = twoColumns ? 8.5 : visible.length > 2 ? 10 : 11;
|
||||
const rowStep = twoColumns ? 13 : 15;
|
||||
const maxCharacters = twoColumns ? 5 : 8;
|
||||
const fontSize = visible.length > 4 ? 8 : 9;
|
||||
const rowStep = visible.length > 4 ? 10 : 12;
|
||||
const startY = origin.y - ((visible.length - 1) * rowStep) / 2;
|
||||
return (
|
||||
<>
|
||||
{visible.map((line, index) => {
|
||||
const column = twoColumns ? Math.floor(index / 4) : 0;
|
||||
const row = twoColumns ? index % 4 : index;
|
||||
return (
|
||||
{visible.map((line, index) => (
|
||||
<text
|
||||
key={`${line}-${index}`}
|
||||
x={6 + column * 49}
|
||||
y={45 + row * rowStep}
|
||||
x={origin.x}
|
||||
y={startY + index * rowStep}
|
||||
textAnchor="middle"
|
||||
fontSize={fontSize}
|
||||
>
|
||||
{truncateSvgLabel(line, maxCharacters)}
|
||||
{line}
|
||||
</text>
|
||||
);
|
||||
})}
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -106,9 +88,7 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
|
||||
for (const house of chart.houses) {
|
||||
linesByHouse.set(house.houseNumber, occupantLines(house, chart));
|
||||
}
|
||||
const hasRetrogradeMarker = chart.houses.some((house) =>
|
||||
house.occupants.some((name) => (chart.planets ?? []).some((p) => p.retrograde && p.name === name)),
|
||||
);
|
||||
const hasRetrogradeMarker = [...linesByHouse.values()].some((lines) => lines.some((line) => line.includes("逆")));
|
||||
|
||||
return (
|
||||
<svg
|
||||
@@ -120,55 +100,51 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
|
||||
className="personal-report-chart-svg"
|
||||
>
|
||||
<defs>
|
||||
{HOUSE_NUMBERS.map((houseNumber) => {
|
||||
const cell = HOUSE_CELLS[houseNumber];
|
||||
return (
|
||||
<clipPath key={houseNumber} id={`${chartId}-house-${houseNumber}`}>
|
||||
<rect x={cell.x + 2} y={cell.y + 2} width={CELL - 4} height={CELL - 4} />
|
||||
</clipPath>
|
||||
);
|
||||
})}
|
||||
{HOUSE_NUMBERS.map((houseNumber) => (
|
||||
<clipPath key={houseNumber} id={`${chartId}-house-${houseNumber}`}>
|
||||
<polygon points={polygonPoints(HOUSE_POLYGONS[houseNumber])} />
|
||||
</clipPath>
|
||||
))}
|
||||
</defs>
|
||||
{HOUSE_NUMBERS.map((houseNumber) => {
|
||||
const cell = HOUSE_CELLS[houseNumber];
|
||||
const house = chart.houses.find((h) => h.houseNumber === houseNumber);
|
||||
const polygon = HOUSE_POLYGONS[houseNumber];
|
||||
const house = chart.houses.find((item) => item.houseNumber === houseNumber);
|
||||
const lines = house ? linesByHouse.get(houseNumber) ?? [] : [];
|
||||
const centroid = polygonCentroid(polygon);
|
||||
const numberAnchor = signNumberAnchor(houseNumber);
|
||||
const ordinal = house ? signOrdinal(house.sign) : null;
|
||||
return (
|
||||
<g key={houseNumber}>
|
||||
<rect
|
||||
x={cell.x}
|
||||
y={cell.y}
|
||||
width={CELL}
|
||||
height={CELL}
|
||||
<polygon
|
||||
points={polygonPoints(polygon)}
|
||||
className="personal-report-chart-cell"
|
||||
/>
|
||||
<text x={cell.x + 5} y={cell.y + 15} fontSize={10} className="personal-report-chart-muted">
|
||||
{houseNumber}
|
||||
</text>
|
||||
{house && (
|
||||
<text x={cell.x + 5} y={cell.y + 30} fontSize={12} fontWeight={600}>
|
||||
{house.sign}
|
||||
{ordinal !== null && (
|
||||
<text
|
||||
x={numberAnchor.x}
|
||||
y={numberAnchor.y}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={10}
|
||||
className="personal-report-chart-muted"
|
||||
>
|
||||
{ordinal}
|
||||
</text>
|
||||
)}
|
||||
<g transform={`translate(${cell.x} ${cell.y})`} clipPath={`url(#${chartId}-house-${houseNumber})`}>
|
||||
<PlanetList lines={lines} />
|
||||
<g clipPath={`url(#${chartId}-house-${houseNumber})`}>
|
||||
<OccupantStack lines={lines} origin={centroid} />
|
||||
</g>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
<rect
|
||||
x={CELL}
|
||||
y={CELL}
|
||||
width={CELL * 2}
|
||||
height={CELL * 2}
|
||||
className="personal-report-chart-core"
|
||||
/>
|
||||
<path
|
||||
d={`M ${CELL * 2} ${CELL} L ${CELL * 3} ${CELL * 2} L ${CELL * 2} ${CELL * 3} L ${CELL} ${CELL * 2} Z`}
|
||||
className="personal-report-chart-diamond"
|
||||
/>
|
||||
{hasRetrogradeMarker && (
|
||||
<text x={CHART_VIEWBOX_WIDTH - 6} y={CHART_VIEWBOX_HEIGHT - 4} textAnchor="end" fontSize={9} className="personal-report-chart-muted">
|
||||
<text
|
||||
x={CHART_VIEWBOX_WIDTH - 6}
|
||||
y={CHART_VIEWBOX_HEIGHT - 8}
|
||||
textAnchor="end"
|
||||
fontSize={9}
|
||||
className="personal-report-chart-muted"
|
||||
>
|
||||
“逆”=逆行
|
||||
</text>
|
||||
)}
|
||||
@@ -176,7 +152,9 @@ export function VedicChartSvg({ chart, ariaLabel }: VedicChartSvgProps) {
|
||||
);
|
||||
}
|
||||
|
||||
export function hasRealChartData(chart: ChartV1): boolean {
|
||||
export const NorthIndianChartSvg = VedicChartSvg;
|
||||
|
||||
export function hasRealChartData(chart: NorthIndianChartModel): boolean {
|
||||
return Array.isArray(chart.houses) && chart.houses.length > 0
|
||||
&& chart.houses.some((house) => Array.isArray(house.occupants) && house.occupants.length > 0);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user