@openephemeris/mcp-server 3.14.0 → 3.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/tools/dev.js CHANGED
@@ -32,66 +32,72 @@ function isDeniedByPrefix(pathname, prefixes) {
32
32
  function isAllowedOperation(method, pathname, allow) {
33
33
  return allow.some((e) => e.method === method && e.path === pathname);
34
34
  }
35
- registerTool({
36
- name: "dev_call",
37
- description: "Call any allowlisted Open Ephemeris API endpoint directly. This is the power-user escape hatch " +
38
- "— use the typed tools (ephemeris_natal_chart, ephemeris_transits, etc.) first for common operations. " +
39
- "Call dev_list_allowed to see all currently available endpoint paths.\n\n" +
40
- "AUTH: Set OPENEPHEMERIS_API_KEY in your environment. See openephemeris.com/dashboard for active plan limits.\n\n" +
41
- "CREDIT COSTS:\n" +
42
- " • Standard chart math (natal, progressed, bazi, vedic, iching): 1 credit\n" +
43
- " • Human Design: 2 credits\n" +
44
- " • Visualization rendering (chart-wheel, bi-wheel, charts/*): 2 credits\n" +
45
- " • Comparative math (synastry, composite, overlay): 3 credits\n" +
46
- " • Predictive ops (transits/search, returns): 5 credits\n" +
47
- " • Predictive transit-chart: 1 credit\n" +
48
- " • ACG / astrocartography: 10 credits (acg/hits: 15 credits)\n" +
49
- " • Calendar endpoints: 10 credits\n" +
50
- " • Catalog / metadata / health endpoints: 0 credits\n" +
51
- " • Compute surcharge: requests > 30s add 1 credit per 30s (predictive, acg, calendar, electional)\n" +
52
- " • format=llm (token-optimized output): available on all tiers\n\n" +
53
- "COMMON CALLS:\n" +
54
- " POST /ephemeris/natal-chart — Full natal chart (body: {subject: {name: 'Name', birth_datetime: {iso: '1990-04-15T14:30:00-05:00'}, birth_location: {latitude: {decimal: 40.0}, longitude: {decimal: -70.0}, timezone: {}}}})\n" +
55
- " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
56
- " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
57
- " POST /predictive/transits/search — Transit event search over a date range\n" +
58
- " POST /predictive/returns/solar — Solar return chart\n" +
59
- " POST /predictive/returns/lunar — Lunar return chart\n" +
60
- " POST /comparative/synastry — Two-person synastry chart\n" +
61
- " POST /comparative/composite — Composite (midpoint) chart\n" +
62
- " POST /human-design/chart — Full HD chart (body: {birth_datetime_utc: '1990-04-15T19:30:00Z'}) — lat/lon optional\n" +
63
- " POST /time/julian-day — Convert date to JD (body: {year: 1987, month: 7, day: 15, hour: 14, minute: 1})\n" +
64
- " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
65
- " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
66
- " GET /ephemeris/agro/daily — Biodynamic farming day quality\n" +
67
- " GET /ephemeris/agro/calendar — Multi-day biodynamic calendar\n" +
68
- " GET /ephemeris/agro/void-of-course — Biodynamic VoC periods\n" +
69
- " GET /eclipse/next-visible — Next eclipse visible from a location (query: lat, lon, type=solar|lunar)\n" +
70
- " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
71
- " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
72
- " GET /tidal/forcing — Gravitational tidal forcing index\n" +
73
- " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
74
- " POST /acg/hits — ACG power at a specific location\n" +
75
- " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
76
- " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
77
- " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
78
- " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
79
- " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
80
- " POST /vedic/chart — Vedic (Jyotish) natal chart (body: {datetime_utc, latitude, longitude})\n" +
81
- " GET /catalogs/bodies — List all supported celestial bodies\n\n" +
82
- "BINARY RESPONSES:\n" +
83
- " • Binary/image endpoints return {content_type, content_length, encoding, data_base64}\n" +
84
- " so callers can decode bytes deterministically.\n\n" +
85
- "ECLIPSE NOTE: Eclipse endpoints accept format=llm via the query param like other endpoints.\n\n" +
86
- "format=llm NOTE: Add query: {format: 'llm'} to natal/synastry/composite/HD endpoints for " +
87
- "compact columnar output optimized for LLM token budgets (availability depends on your current plan).",
88
- inputSchema: {
35
+ // Shared reference block appended to both read/write proxy tools. Names the
36
+ // target API (Open Ephemeris) explicitly — required by the directory for
37
+ // freeform-path tools plus credit costs and common calls.
38
+ const DEV_API_REFERENCE = "Target API: Open Ephemeris REST API (https://api.openephemeris.com). " +
39
+ "Call dev_list_allowed to see all currently available endpoint paths.\n\n" +
40
+ "AUTH: Set OPENEPHEMERIS_API_KEY in your environment. See openephemeris.com/dashboard for active plan limits.\n\n" +
41
+ "CREDIT COSTS:\n" +
42
+ " • Standard chart math (natal, progressed, bazi, vedic, iching): 1 credit\n" +
43
+ " • Human Design: 2 credits\n" +
44
+ " • Visualization rendering (chart-wheel, bi-wheel, charts/*): 2 credits\n" +
45
+ " • Comparative math (synastry, composite, overlay): 3 credits\n" +
46
+ " • Predictive ops (transits/search, returns): 5 credits\n" +
47
+ " • Predictive transit-chart: 1 credit\n" +
48
+ " • ACG / astrocartography: 10 credits (acg/hits: 15 credits)\n" +
49
+ " • Calendar endpoints: 10 credits\n" +
50
+ " • Catalog / metadata / health endpoints: 0 credits\n" +
51
+ " • Compute surcharge: requests > 30s add 1 credit per 30s (predictive, acg, calendar, electional)\n" +
52
+ " • format=llm (token-optimized output): available on all tiers\n\n" +
53
+ "COMMON CALLS:\n" +
54
+ " POST /ephemeris/natal-chart — Full natal chart (body: {subject: {name: 'Name', birth_datetime: {iso: '1990-04-15T14:30:00-05:00'}, birth_location: {latitude: {decimal: 40.0}, longitude: {decimal: -70.0}, timezone: {}}}})\n" +
55
+ " POST /ephemeris/natal/batch — Up to 50 natal charts in one request\n" +
56
+ " POST /ephemeris/relocation — Relocated chart (same natal, new location)\n" +
57
+ " POST /predictive/transits/search — Transit event search over a date range\n" +
58
+ " POST /predictive/returns/solar — Solar return chart\n" +
59
+ " POST /predictive/returns/lunar — Lunar return chart\n" +
60
+ " POST /comparative/synastry — Two-person synastry chart\n" +
61
+ " POST /comparative/composite — Composite (midpoint) chart\n" +
62
+ " POST /human-design/chart — Full HD chart (body: {birth_datetime_utc: '1990-04-15T19:30:00Z'}) — lat/lon optional\n" +
63
+ " POST /time/julian-day — Convert date to JD (body: {year: 1987, month: 7, day: 15, hour: 14, minute: 1})\n" +
64
+ " GET /ephemeris/moon/phase — Current/queried moon phase\n" +
65
+ " GET /ephemeris/moon/void-of-course — Next void-of-course period\n" +
66
+ " GET /ephemeris/agro/daily — Biodynamic farming day quality\n" +
67
+ " GET /ephemeris/agro/calendar — Multi-day biodynamic calendar\n" +
68
+ " GET /ephemeris/agro/void-of-course — Biodynamic VoC periods\n" +
69
+ " GET /eclipse/next-visible — Next eclipse visible from a location (query: lat, lon, type=solar|lunar)\n" +
70
+ " GET /eclipse/solar/global — Next global solar eclipse (query: date=YYYY-MM-DD)\n" +
71
+ " GET /eclipse/solar/local — Local solar eclipse (query: lat, lon)\n" +
72
+ " GET /tidal/forcing — Gravitational tidal forcing index\n" +
73
+ " POST /acg/power-lines — Astrocartography power lines (lat/lon GeoJSON)\n" +
74
+ " POST /acg/hits — ACG power at a specific location\n" +
75
+ " GET /calendar/astrology/moon-phases — Moon phase calendar for a date range\n" +
76
+ " GET /location/autocomplete — Geocode a place name (query: q=City Name)\n" +
77
+ " POST /timezone/lookup — Resolve timezone + UTC offset for a location\n" +
78
+ " POST /chinese/bazi — Chinese Ba Zi (Four Pillars) chart (body: {year, month, day, hour})\n" +
79
+ " GET /chinese/zodiac — Chinese zodiac year element/animal\n" +
80
+ " POST /vedic/chart — Vedic (Jyotish) natal chart (body: {datetime_utc, latitude, longitude})\n" +
81
+ " GET /catalogs/bodies — List all supported celestial bodies\n\n" +
82
+ "BINARY RESPONSES:\n" +
83
+ " • Binary/image endpoints return {content_type, content_length, encoding, data_base64}\n" +
84
+ " so callers can decode bytes deterministically.\n\n" +
85
+ "ECLIPSE NOTE: Eclipse endpoints accept format=llm via the query param like other endpoints.\n\n" +
86
+ "format=llm NOTE: Add query: {format: 'llm'} to natal/synastry/composite/HD endpoints for " +
87
+ "compact columnar output optimized for LLM token budgets (availability depends on your current plan).";
88
+ // The read and write proxies are kept as separate tools (not one method-switching
89
+ // tool) so that safe GET reads never share a surface with state-changing writes —
90
+ // a hard requirement of the Anthropic connector directory.
91
+ const READ_METHODS = ["GET"];
92
+ const WRITE_METHODS = ["POST", "PUT", "PATCH", "DELETE"];
93
+ function makeProxyInputSchema(methods) {
94
+ return {
89
95
  type: "object",
90
96
  properties: {
91
97
  method: {
92
98
  type: "string",
93
- enum: ["GET", "POST", "PUT", "PATCH", "DELETE"],
94
- description: "HTTP method",
99
+ enum: methods,
100
+ description: methods.length === 1 ? `HTTP method (always ${methods[0]})` : "HTTP method",
95
101
  },
96
102
  path: {
97
103
  type: "string",
@@ -100,10 +106,10 @@ registerTool({
100
106
  query: {
101
107
  type: "object",
102
108
  additionalProperties: true,
103
- description: "Query params for GET requests (optional)",
109
+ description: "Query params (optional)",
104
110
  },
105
111
  body: {
106
- description: "JSON body for POST/PUT/PATCH (optional)",
112
+ description: "JSON request body (optional)",
107
113
  },
108
114
  preset: {
109
115
  type: "string",
@@ -121,15 +127,16 @@ registerTool({
121
127
  description: "Legacy convenience (deprecated): if provided, set query.output_mode and also map to query.preset/query.format when possible.",
122
128
  },
123
129
  },
124
- required: ["method", "path"],
130
+ required: ["path"],
125
131
  additionalProperties: false,
126
- },
127
- outputSchema: OUTPUT_SCHEMA_JSON,
128
- handler: async (args) => {
132
+ };
133
+ }
134
+ function makeProxyHandler(allowedMethods, defaultMethod) {
135
+ return async (args) => {
129
136
  validateRequired(args, ["path"]);
130
- const methodStr = String(args.method || "GET").toUpperCase();
131
- if (!["GET", "POST", "PUT", "PATCH", "DELETE"].includes(methodStr)) {
132
- throw new Error(`Invalid HTTP method: ${methodStr}`);
137
+ const methodStr = String(args.method || defaultMethod).toUpperCase();
138
+ if (!allowedMethods.includes(methodStr)) {
139
+ throw new Error(`Method ${methodStr} not permitted by this tool. Allowed: ${allowedMethods.join(", ")}.`);
133
140
  }
134
141
  const method = methodStr;
135
142
  const pathname = String(args.path);
@@ -177,21 +184,48 @@ registerTool({
177
184
  params: query,
178
185
  data: body,
179
186
  });
180
- },
187
+ };
188
+ }
189
+ registerTool({
190
+ name: "dev_read_api",
191
+ description: "Read from any allowlisted Open Ephemeris API endpoint via HTTP GET. This is the read-only " +
192
+ "power-user escape hatch — use the typed tools (ephemeris_natal_chart, ephemeris_transits, etc.) " +
193
+ "first for common operations. For endpoints that compute via POST (natal-chart, synastry, etc.), " +
194
+ "use dev_write_api.\n\n" +
195
+ DEV_API_REFERENCE,
196
+ inputSchema: makeProxyInputSchema(READ_METHODS),
197
+ outputSchema: OUTPUT_SCHEMA_JSON,
198
+ // GET-only: never mutates server state.
199
+ annotations: { title: "API Read", readOnlyHint: true, openWorldHint: true },
200
+ handler: makeProxyHandler(READ_METHODS, "GET"),
201
+ });
202
+ registerTool({
203
+ name: "dev_write_api",
204
+ description: "Send a request to any allowlisted Open Ephemeris API endpoint via POST/PUT/PATCH/DELETE. " +
205
+ "Most chart computations (natal-chart, synastry, composite, transits/search, returns, ACG) are " +
206
+ "POST endpoints and use this tool. Use the typed tools first for common operations; use dev_read_api " +
207
+ "for GET endpoints.\n\n" +
208
+ DEV_API_REFERENCE,
209
+ inputSchema: makeProxyInputSchema(WRITE_METHODS),
210
+ outputSchema: OUTPUT_SCHEMA_JSON,
211
+ // Issues POST/PUT/PATCH/DELETE — must not be advertised as read-only.
212
+ annotations: { title: "API Write", readOnlyHint: false, openWorldHint: true },
213
+ handler: makeProxyHandler(WRITE_METHODS, "POST"),
181
214
  });
182
215
  registerTool({
183
216
  name: "dev_list_allowed",
184
217
  description: "List all API operations (method + path) that this MCP instance is authorized to call. " +
185
218
  "Returns endpoint entries grouped by method, plus the active deny rules. " +
186
- "Use this to discover what's available before calling dev_call, or to verify an endpoint path. " +
219
+ "Use this to discover what's available before calling dev_read_api / dev_write_api, or to verify an endpoint path. " +
187
220
  "Typed shortcut tools (ephemeris_natal_chart, ephemeris_transits, etc.) cover the most common operations — " +
188
- "check those first before reaching for dev_call.",
221
+ "check those first before reaching for the generic proxies.",
189
222
  inputSchema: {
190
223
  type: "object",
191
224
  properties: {},
192
225
  additionalProperties: false,
193
226
  },
194
227
  outputSchema: OUTPUT_SCHEMA_JSON,
228
+ annotations: { title: "List Allowed API Operations", readOnlyHint: true, openWorldHint: false },
195
229
  handler: async () => {
196
230
  const allowlist = loadAllowlist();
197
231
  return {
@@ -52,6 +52,16 @@ export declare function initTools(profile?: ToolProfile): Promise<void>;
52
52
  * Throws an error if any of the required keys are missing or empty.
53
53
  */
54
54
  export declare function validateRequired(args: any, requiredKeys: string[]): void;
55
+ /**
56
+ * Returns `value` only if it is one of `allowed`, otherwise `undefined`.
57
+ *
58
+ * Use for any tool argument that gets interpolated into a backend URL/query
59
+ * string. Even though the inputSchema declares an `enum`, the MCP host does
60
+ * not guarantee enum enforcement, so a hostile client could smuggle extra
61
+ * query parameters (e.g. style="dark&admin=1"). Whitelisting here closes that
62
+ * injection vector at the boundary (DATA-6).
63
+ */
64
+ export declare function pickEnum<T extends string>(value: unknown, allowed: readonly T[]): T | undefined;
55
65
  /**
56
66
  * Throws an error if only one of the coordinate pair is provided.
57
67
  */
@@ -44,7 +44,7 @@ export async function initTools(profile) {
44
44
  const resolvedProfile = (profile || process.env.OPENEPHEMERIS_PROFILE || process.env.ASTROMCP_PROFILE || "dev").toLowerCase();
45
45
  // Always register auth tools (available in all profiles).
46
46
  await import("./auth.js");
47
- // Always register the generic proxy tools (dev.call + dev.list_allowed).
47
+ // Always register the generic proxy tools (dev_read_api + dev_write_api + dev_list_allowed).
48
48
  await import("./dev.js");
49
49
  if (resolvedProfile === "dev") {
50
50
  // ── FULL BUILD: all specialized tools ───────────────────────────
@@ -93,6 +93,20 @@ export function validateRequired(args, requiredKeys) {
93
93
  throw new Error(`Missing required arguments: ${missing.join(", ")}`);
94
94
  }
95
95
  }
96
+ /**
97
+ * Returns `value` only if it is one of `allowed`, otherwise `undefined`.
98
+ *
99
+ * Use for any tool argument that gets interpolated into a backend URL/query
100
+ * string. Even though the inputSchema declares an `enum`, the MCP host does
101
+ * not guarantee enum enforcement, so a hostile client could smuggle extra
102
+ * query parameters (e.g. style="dark&admin=1"). Whitelisting here closes that
103
+ * injection vector at the boundary (DATA-6).
104
+ */
105
+ export function pickEnum(value, allowed) {
106
+ return typeof value === "string" && allowed.includes(value)
107
+ ? value
108
+ : undefined;
109
+ }
96
110
  /**
97
111
  * Throws an error if only one of the coordinate pair is provided.
98
112
  */
@@ -145,7 +145,7 @@ registerTool({
145
145
  additionalProperties: false,
146
146
  },
147
147
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
148
- annotations: { title: "BaZi Day Master Analysis", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
148
+ annotations: { title: "BaZi Ten Gods", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
149
149
  handler: async (args) => {
150
150
  const body = { ...parseBaziArgs(args) };
151
151
  applyVisualConfig(body, args);
@@ -180,7 +180,7 @@ registerTool({
180
180
  additionalProperties: false,
181
181
  },
182
182
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
183
- annotations: { title: "BaZi Clash & Combination", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
183
+ annotations: { title: "BaZi Element Balance", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
184
184
  handler: async (args) => {
185
185
  const body = { ...parseBaziArgs(args) };
186
186
  applyVisualConfig(body, args);
@@ -223,7 +223,7 @@ registerTool({
223
223
  additionalProperties: false,
224
224
  },
225
225
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
226
- annotations: { title: "BaZi Ten Gods", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
226
+ annotations: { title: "BaZi Luck Pillars", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
227
227
  handler: async (args) => {
228
228
  if (!args.gender) {
229
229
  throw new Error("gender is required for luck pillar calculation. " +
@@ -266,7 +266,7 @@ registerTool({
266
266
  additionalProperties: false,
267
267
  },
268
268
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
269
- annotations: { title: "BaZi Luck Pillars", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
269
+ annotations: { title: "BaZi Annual Pillar", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
270
270
  handler: async (args) => {
271
271
  if (!args.year) {
272
272
  throw new Error("year is required. Provide a Gregorian year (1–9999), e.g. year=2025.");
@@ -321,7 +321,7 @@ registerTool({
321
321
  additionalProperties: false,
322
322
  },
323
323
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
324
- annotations: { title: "BaZi Annual Pillars", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
324
+ annotations: { title: "BaZi Compatibility", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
325
325
  handler: async (args) => {
326
326
  const chartAComponents = parseBaziArgs({
327
327
  year: args.chart_a_year,
@@ -381,7 +381,7 @@ registerTool({
381
381
  additionalProperties: false,
382
382
  },
383
383
  outputSchema: OUTPUT_SCHEMA_IMAGE_AND_JSON,
384
- annotations: { title: "BaZi Compatibility", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
384
+ annotations: { title: "BaZi Chart (Visual)", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
385
385
  handler: async (args) => {
386
386
  const { year, month, day, hour } = parseBaziArgs(args);
387
387
  const body = {
@@ -1,4 +1,4 @@
1
- import { registerTool, validateRequired } from "../index.js";
1
+ import { registerTool, validateRequired, pickEnum } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE } from "../output-schemas.js";
4
4
  const DATETIME_DESC = "ISO 8601 datetime string, e.g. '1990-04-15T14:30:00' (local time at birth location). " +
@@ -71,7 +71,8 @@ registerTool({
71
71
  const mappedCode = HOUSE_SYSTEM_MAP[args.house_system] || "P";
72
72
  body.configuration = { house_system: mappedCode };
73
73
  }
74
- const styleParam = args.style ? `&style=${args.style}` : "";
74
+ const style = pickEnum(args.style, ["light", "dark", "mono"]);
75
+ const styleParam = style ? `&style=${style}` : "";
75
76
  return await getActiveClient().post(`/visualization/bi-wheel?format=svg&size=800${styleParam}`, body);
76
77
  },
77
78
  });
@@ -1,4 +1,4 @@
1
- import { registerTool, validateRequired } from "../index.js";
1
+ import { registerTool, validateRequired, pickEnum } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_IMAGE } from "../output-schemas.js";
4
4
  const DATETIME_DESC = "ISO 8601 datetime string, e.g. '1990-04-15T14:30:00' (local time at birth location). " +
@@ -68,7 +68,8 @@ registerTool({
68
68
  const mappedCode = HOUSE_SYSTEM_MAP[args.house_system] || "P";
69
69
  body.configuration = { house_system: mappedCode };
70
70
  }
71
- const styleParam = args.style ? `&style=${args.style}` : "";
71
+ const style = pickEnum(args.style, ["light", "dark", "mono"]);
72
+ const styleParam = style ? `&style=${style}` : "";
72
73
  return await getActiveClient().post(`/visualization/chart-wheel?format=svg&size=800${styleParam}`, body);
73
74
  },
74
75
  });
@@ -136,7 +136,7 @@ registerTool({
136
136
  additionalProperties: false,
137
137
  },
138
138
  outputSchema: OUTPUT_SCHEMA_JSON,
139
- annotations: { title: "Planet Compatibility Score", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
139
+ annotations: { title: "Synastry House Overlay", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
140
140
  handler: async (args) => {
141
141
  validateRequired(args, [
142
142
  "person_a_datetime", "person_a_latitude", "person_a_longitude",
@@ -179,7 +179,7 @@ registerTool({
179
179
  additionalProperties: false,
180
180
  },
181
181
  outputSchema: OUTPUT_SCHEMA_JSON,
182
- annotations: { title: "Composite Chart Aspects", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
182
+ annotations: { title: "Transits to Natal Chart", readOnlyHint: true, destructiveHint: false, idempotentHint: true },
183
183
  handler: async (args) => {
184
184
  validateRequired(args, ["natal_datetime", "natal_latitude", "natal_longitude"]);
185
185
  // Backend expects: { subject: {...}, transit_datetime?: {...} }
@@ -1,4 +1,4 @@
1
- import { registerTool, validateRequired } from "../index.js";
1
+ import { registerTool, validateRequired, pickEnum } from "../index.js";
2
2
  import { getActiveClient } from "../../backend/client.js";
3
3
  import { OUTPUT_SCHEMA_JSON } from "../output-schemas.js";
4
4
  /** Append 'Z' if no timezone offset is present in the datetime string. */
@@ -47,8 +47,9 @@ registerTool({
47
47
  const body = {
48
48
  birth_datetime_utc: ensureTimezone(args.datetime),
49
49
  };
50
- const format = args.format || "svg";
51
- const styleParam = args.style ? `&style=${args.style}` : "";
50
+ const format = pickEnum(args.format, ["svg", "png"]) || "svg";
51
+ const style = pickEnum(args.style, ["light", "dark", "mono"]);
52
+ const styleParam = style ? `&style=${style}` : "";
52
53
  return await getActiveClient().post(`/visualization/bodygraph?format=${format}&size=800${styleParam}`, body);
53
54
  },
54
55
  });
package/dist/ui/bazi.html CHANGED
@@ -1,26 +1,26 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8" />
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
- <meta name="color-scheme" content="dark light" />
7
- <title>BaZi Four Pillars Explorer</title>
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <meta name="color-scheme" content="dark light" />
7
+ <title>BaZi Four Pillars Explorer</title>
8
8
 
9
9
 
10
10
  <style>*,:before,:after{box-sizing:border-box;margin:0;padding:0}:root{--bg-primary:#090b0f;--bg-secondary:#12161d;--bg-tertiary:#1c222b;--bg-hover:#272e3b;--border:#282e38;--border-subtle:#1d2229;--text-primary:#e1e5eb;--text-secondary:#9da5b1;--text-muted:#6c727b;--accent:#62b3ff;--accent-warm:#e6ac3d;--radius:8px;--radius-sm:4px;--radius-lg:12px;--wuxing-wood:#31aa40;--wuxing-fire:#f14d4c;--wuxing-earth:#ca8a10;--wuxing-metal:#9fafc8;--wuxing-water:#0099e0;--wuxing-wood-bg:#1b451e;--wuxing-fire-bg:#65201e;--wuxing-earth-bg:#583c10;--wuxing-metal-bg:#3c434d;--wuxing-water-bg:#003f60;--dm-border:#d29922;--dm-bg:#201a10;--lightningcss-light: ;--lightningcss-dark:initial;color-scheme:dark}@supports (color:color(display-p3 0 0 0)){:root{--bg-primary:color(display-p3 .036047 .0438664 .059078);--bg-secondary:color(display-p3 .0739679 .0866504 .111186);--bg-tertiary:color(display-p3 .114361 .13221 .166818);--bg-hover:color(display-p3 .15679 .180114 .225406);--border:color(display-p3 .161376 .180145 .216325);--border-subtle:color(display-p3 .118728 .13221 .158123);--text-primary:color(display-p3 .883958 .896627 .920543);--text-secondary:color(display-p3 .622745 .646433 .6914);--text-muted:color(display-p3 .429297 .445909 .477439);--accent:color(display-p3 .446063 .694439 1.01077);--accent-warm:color(display-p3 .866978 .684443 .324483);--wuxing-wood:color(display-p3 .343741 .657851 .307102);--wuxing-fire:color(display-p3 .872583 .352089 .325751);--wuxing-earth:color(display-p3 .754604 .550516 .200367);--wuxing-metal:color(display-p3 .636614 .68404 .77495);--wuxing-water:color(display-p3 .226818 .589506 .875825);--wuxing-wood-bg:color(display-p3 .150499 .267148 .135899);--wuxing-fire-bg:color(display-p3 .36387 .143128 .129405);--wuxing-earth-bg:color(display-p3 .328547 .240912 .0972974);--wuxing-metal-bg:color(display-p3 .240898 .260909 .299258);--wuxing-water-bg:color(display-p3 .0921333 .242221 .364185);--dm-border:color(display-p3 .789908 .60997 .245434);--dm-bg:color(display-p3 .119765 .101379 .0671137)}}@supports (color:lab(0% 0 0)){:root{--bg-primary:lab(3.02674% -.179462 -2.15476);--bg-secondary:lab(7.16596% -.441141 -5.41423);--bg-tertiary:lab(12.9118% -.60384 -7.27841);--bg-hover:lab(18.6882% -.727877 -9.08878);--border:lab(18.7165% -.689313 -7.30941);--border-subtle:lab(12.9391% -.548743 -5.49436);--text-primary:lab(90.6878% -.517368 -3.73452);--text-secondary:lab(67.45% -.932217 -7.41489);--text-muted:lab(47.7476% -.701487 -5.56231);--accent:lab(70.4043% -7.21234 -50.7672);--accent-warm:lab(74.5248% 14.3487 62.5064);--wuxing-wood:lab(61.5358% -50.6384 43.17);--wuxing-fire:lab(57.3944% 63.124 38.0626);--wuxing-earth:lab(62.7263% 19.0613 64.9727);--wuxing-metal:lab(70.8424% -1.5474 -14.6858);--wuxing-water:lab(59.1191% -14.8305 -49.6808);--wuxing-wood-bg:lab(25.5679% -22.4482 18.6532);--wuxing-fire-bg:lab(23.6033% 31.5073 18.7032);--wuxing-earth-bg:lab(28.0231% 9.19801 30.8603);--wuxing-metal-bg:lab(28.0014% -.778109 -7.34471);--wuxing-water-bg:lab(24.4811% -7.8918 -24.9024);--dm-border:lab(67.5362% 14.8294 64.5914);--dm-bg:lab(9.55197% 1.60985 7.31485)}}@media (prefers-color-scheme:light){:root{--bg-primary:#f3f5f9;--bg-secondary:#e7ebf2;--bg-tertiary:#d8dee8;--bg-hover:#d0d8e5;--border:#bdc5d1;--border-subtle:#d2d8e2;--text-primary:#0f1216;--text-secondary:#414853;--text-muted:#6c727b;--accent:#0065b2;--accent-warm:#966800;--lightningcss-light:initial;--lightningcss-dark: ;color-scheme:light;--wuxing-wood:#005f0e;--wuxing-fire:#a20519;--wuxing-earth:#805300;--wuxing-metal:#48566c;--wuxing-water:#005380;--wuxing-wood-bg:#c0e3c0;--wuxing-fire-bg:#fdc9c4;--wuxing-earth-bg:#f2daba;--wuxing-metal-bg:#d6dfec;--wuxing-water-bg:#b4defc;--dm-border:#966800;--dm-bg:#f6ede0}@supports (color:color(display-p3 0 0 0)){:root{--bg-primary:color(display-p3 .95471 .961142 .973263);--bg-secondary:color(display-p3 .909797 .922535 .946581);--bg-tertiary:color(display-p3 .852455 .871315 .906983);--bg-hover:color(display-p3 .821139 .84609 .893374);--border:color(display-p3 .745559 .770059 .816515);--border-subtle:color(display-p3 .826933 .845684 .881152);--text-primary:color(display-p3 .0608605 .0691527 .0850621);--text-secondary:color(display-p3 .261513 .281801 .32064);--text-muted:color(display-p3 .429297 .445909 .477439);--accent:color(display-p3 .143802 .388951 .683672);--accent-warm:color(display-p3 .596632 .396992 -.158844);--wuxing-wood:color(display-p3 .0968244 .374385 .0683277);--wuxing-fire:color(display-p3 .580784 .119608 .128804);--wuxing-earth:color(display-p3 .489279 .326588 -.0287041);--wuxing-metal:color(display-p3 .294019 .335238 .415486);--wuxing-water:color(display-p3 -.0775243 .316124 .547443);--wuxing-wood-bg:color(display-p3 .778394 .884493 .76257);--wuxing-fire-bg:color(display-p3 .959986 .795906 .773571);--wuxing-earth-bg:color(display-p3 .93331 .858308 .743829);--wuxing-metal-bg:color(display-p3 .84663 .871726 .919277);--wuxing-water-bg:color(display-p3 .739548 .864306 .975015);--dm-border:color(display-p3 .596632 .396992 -.158844);--dm-bg:color(display-p3 .957692 .931855 .88349)}}@supports (color:lab(0% 0 0)){:root{--bg-primary:lab(96.5044% -.267148 -1.87216);--bg-secondary:lab(93.0079% -.518024 -3.73493);--bg-tertiary:lab(88.3503% -.749856 -5.58736);--bg-hover:lab(86.0117% -.963002 -7.43048);--border:lab(79.0511% -.952899 -7.42528);--border-subtle:lab(86.0302% -.748128 -5.58645);--text-primary:lab(5.23796% -.285208 -3.0582);--text-secondary:lab(30.3224% -.794604 -7.35167);--text-muted:lab(47.7476% -.701487 -5.56231);--accent:lab(41.1205% -1.70796 -50.1423);--accent-warm:lab(47.6194% 20.1868 104.51);--wuxing-wood:lab(34.5429% -45.3762 42.1829);--wuxing-fire:lab(34.3495% 57.1812 37.8813);--wuxing-earth:lab(39.4811% 17.4358 64.8795);--wuxing-metal:lab(36.0172% -1.09252 -14.5181);--wuxing-water:lab(32.2784% -8.65509 -43.065);--wuxing-wood-bg:lab(86.8562% -16.7675 13.1583);--wuxing-fire-bg:lab(85.5193% 18.485 10.0423);--wuxing-earth-bg:lab(88.4503% 4.9783 19.0112);--wuxing-metal-bg:lab(88.3319% -.966102 -7.43206);--wuxing-water-bg:lab(86.1283% -8.87641 -19.3614);--dm-border:lab(47.6194% 20.1868 104.51);--dm-bg:lab(94.2538% 1.22291 7.5882)}}}html,body{background:var(--bg-primary);width:100%;height:100%;color:var(--text-primary);font-family:system-ui,-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Noto Sans,Ubuntu,Cantarell,Helvetica Neue,sans-serif;font-size:13px;line-height:1.5;overflow:hidden}body{flex-direction:column;display:flex}#app{flex-direction:column;flex:1;display:flex;overflow:hidden}#loading-state{color:var(--text-secondary);flex-direction:column;flex:1;justify-content:center;align-items:center;gap:12px;display:flex}#loading-state[hidden]{display:none}.oe-loader{width:48px;height:48px;animation:1.2s linear infinite spin}.oe-loader circle{fill:none;stroke:var(--accent);stroke-width:3px;stroke-dasharray:200;stroke-dashoffset:80px;stroke-linecap:round}@keyframes spin{to{transform:rotate(360deg)}}.chart-status{background:var(--bg-secondary);border-top:1px solid var(--border-subtle);color:var(--text-muted);opacity:0;z-index:50;padding:6px 14px;font-size:11px;transition:opacity .2s,transform .2s;position:fixed;bottom:0;left:0;right:0;transform:translateY(100%)}.chart-status.visible{opacity:1;transform:translateY(0)}#bazi-container{flex-direction:column;flex:1;gap:16px;padding:16px;display:flex;overflow-y:auto}#bazi-container[hidden]{display:none}.bazi-pillars{grid-template-columns:repeat(4,1fr);gap:8px;animation:.6s ease-out bazi-fade-in;display:grid}@keyframes bazi-fade-in{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.bazi-pillar{border:1px solid var(--border-subtle);border-radius:var(--radius);background:var(--bg-secondary);cursor:pointer;flex-direction:column;transition:border-color .15s,box-shadow .15s,transform .15s;display:flex;overflow:hidden}.bazi-pillar:hover{border-color:var(--border);transform:translateY(-1px);box-shadow:0 2px 12px rgba(0,0,0,.1);box-shadow:0 2px 12px color(display-p3 0 0 0/.1);box-shadow:0 2px 12px lab(0% 0 0/.1)}.bazi-pillar--dm{border-color:var(--dm-border);background:var(--dm-bg);box-shadow:inset 3px 0 0 var(--dm-border), inset -3px 0 0 var(--dm-border)}.bazi-pillar--dm:hover{border-color:var(--dm-border);box-shadow:inset 3px 0 0 var(--dm-border), inset -3px 0 0 var(--dm-border), 0 4px 16px rgba(210,153,34,.15)}@supports (color:color(display-p3 0 0 0)){.bazi-pillar--dm:hover{box-shadow:inset 3px 0 0 var(--dm-border), inset -3px 0 0 var(--dm-border), 0 4px 16px color(display-p3 .789908 .60997 .245434/.15)}}@supports (color:lab(0% 0 0)){.bazi-pillar--dm:hover{box-shadow:inset 3px 0 0 var(--dm-border), inset -3px 0 0 var(--dm-border), 0 4px 16px lab(67.5362% 14.8294 64.5914/.15)}}.bazi-pillar-header{text-transform:uppercase;letter-spacing:.08em;color:var(--text-muted);background:var(--bg-tertiary);border-bottom:1px solid var(--border-subtle);justify-content:center;align-items:center;gap:4px;padding:6px 8px;font-size:10px;font-weight:700;display:flex}.bazi-pillar--dm .bazi-pillar-header{color:var(--dm-border)}.dm-badge{background:var(--dm-border);color:#090b0f;color:color(display-p3 .036047 .0438664 .059078);color:lab(3.02674% -.179462 -2.15476);letter-spacing:.04em;border-radius:10px;padding:1px 5px;font-size:9px;font-weight:700}@media (prefers-color-scheme:light){.dm-badge{color:#fcf8f1;color:color(display-p3 .985424 .972455 .948118);color:lab(97.7087% .579864 3.77247)}}.bazi-stem-zone{border-bottom:1px solid var(--border-subtle);flex-direction:column;align-items:center;gap:2px;padding:10px 8px;display:flex}.bazi-stem-chinese{font-size:28px;font-weight:700;line-height:1.1}.bazi-stem-name{color:var(--text-secondary);font-size:11px;font-weight:600}.bazi-stem-element{color:var(--bg-primary);border-radius:10px;padding:1px 6px;font-size:10px;font-weight:500}@media (prefers-color-scheme:light){.bazi-stem-element{color:#fff6f8;color:color(display-p3 .992746 .964982 .972458);color:lab(97.5544% 3.37881 .0330448)}}.bazi-branch-zone{border-bottom:1px solid var(--border-subtle);flex-direction:column;align-items:center;gap:2px;padding:10px 8px;display:flex}.bazi-branch-animal-emoji{font-size:22px;line-height:1.1}.bazi-branch-name{color:var(--text-secondary);font-size:11px;font-weight:600}.bazi-branch-animal{color:var(--text-muted);font-size:10px}.bazi-hidden-zone{flex-direction:column;align-items:center;gap:3px;min-height:48px;padding:6px 8px;display:flex}.bazi-hidden-label{text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:1px;font-size:9px;font-weight:600}.bazi-hidden-stems{flex-wrap:wrap;justify-content:center;gap:6px;display:flex}.bazi-hidden-stem{flex-direction:column;align-items:center;gap:1px;display:flex}.bazi-hidden-char{font-size:16px;font-weight:600;line-height:1.1}.bazi-hidden-strength{opacity:.7;border-radius:50%;width:6px;height:6px}.bazi-hidden-strength--main{opacity:1}.bazi-hidden-strength--secondary{opacity:.65}.bazi-hidden-strength--residual{opacity:.35}.bazi-ten-god-tag{background:var(--bg-hover);color:var(--text-secondary);border-radius:10px;padding:1px 5px;font-size:9px;font-weight:600}.bazi-balance{background:var(--bg-secondary);border:1px solid var(--border-subtle);border-radius:var(--radius);flex-direction:column;gap:8px;padding:12px 14px;animation:.8s ease-out .2s both bazi-fade-in;display:flex}.bazi-balance-header{justify-content:space-between;align-items:center;display:flex}.bazi-balance-title{text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);font-size:11px;font-weight:700}.bazi-balance-strength{background:var(--bg-hover);color:var(--text-secondary);border-radius:10px;padding:2px 8px;font-size:11px;font-weight:600}.bazi-balance-strength--strong{color:var(--wuxing-wood)}.bazi-balance-strength--weak{color:var(--wuxing-fire)}.bazi-bar-container{border-radius:var(--radius-sm);border:1px solid var(--border-subtle);height:28px;display:flex;overflow:hidden}.bazi-bar-segment{min-width:24px;color:var(--text-primary);justify-content:center;align-items:center;font-size:10px;font-weight:700;transition:flex .4s ease-out;display:flex;position:relative;overflow:hidden}.bazi-bar-segment-label{opacity:.8;white-space:nowrap;font-size:9px}.bazi-bar-legend{flex-wrap:wrap;justify-content:center;gap:12px;display:flex}.bazi-legend-item{color:var(--text-secondary);cursor:pointer;align-items:center;gap:4px;font-size:11px;transition:color .15s;display:flex}.bazi-legend-item:hover{color:var(--text-primary)}.bazi-legend-dot{border-radius:50%;flex-shrink:0;width:8px;height:8px}.bazi-legend-pct{font-variant-numeric:tabular-nums;font-weight:700}.bazi-legend-score{color:var(--text-muted);font-size:10px}.bazi-yongshen{border-radius:var(--radius-sm);background:var(--bg-tertiary);border:1px solid var(--border-subtle);color:var(--text-secondary);justify-content:center;align-items:center;gap:6px;padding:6px 12px;font-size:12px;display:flex}.bazi-yongshen-label{color:var(--text-muted);font-weight:500}.bazi-yongshen-value{font-weight:700}.bazi-drawer-overlay{z-index:60;opacity:0;background:rgba(0,0,0,.4);background:color(display-p3 0 0 0/.4);background:lab(0% 0 0/.4);transition:opacity .2s;position:fixed;top:0;bottom:0;left:0;right:0}.bazi-drawer-overlay.visible{opacity:1}.bazi-drawer{background:var(--bg-secondary);border-top:1px solid var(--border);border-radius:var(--radius-lg) var(--radius-lg) 0 0;z-index:61;max-height:60vh;padding:16px;transition:transform .25s ease-out;position:fixed;bottom:0;left:0;right:0;overflow-y:auto;transform:translateY(100%)}.bazi-drawer.visible{transform:translateY(0)}.bazi-drawer-handle{background:var(--border);border-radius:2px;width:36px;height:4px;margin:0 auto 12px}.bazi-drawer-title{align-items:center;gap:8px;margin-bottom:12px;font-size:15px;font-weight:700;display:flex}.bazi-drawer-section{margin-bottom:12px}.bazi-drawer-section-title{text-transform:uppercase;letter-spacing:.06em;color:var(--text-muted);margin-bottom:6px;font-size:10px;font-weight:700}.bazi-drawer-ten-god{background:var(--bg-tertiary);border-radius:var(--radius-sm);align-items:center;gap:10px;margin-bottom:6px;padding:8px 10px;display:flex}.bazi-drawer-tg-chinese{font-size:22px;font-weight:700}.bazi-drawer-tg-info{flex-direction:column;gap:1px;display:flex}.bazi-drawer-tg-name{font-size:13px;font-weight:600}.bazi-drawer-tg-category{color:var(--text-muted);text-transform:capitalize;font-size:11px}.bazi-drawer-hidden-table{border-collapse:collapse;width:100%;font-size:12px}.bazi-drawer-hidden-table th{text-transform:uppercase;letter-spacing:.04em;color:var(--text-muted);text-align:left;border-bottom:1px solid var(--border-subtle);padding:4px 8px;font-size:10px;font-weight:700}.bazi-drawer-hidden-table td{border-bottom:1px solid var(--border-subtle);color:var(--text-secondary);padding:5px 8px}.bazi-drawer-hidden-table tr:last-child td{border-bottom:none}.bazi-actions{flex-wrap:wrap;gap:8px;animation:.8s ease-out .4s both bazi-fade-in;display:flex}.action-btn{border-radius:var(--radius);border:1px solid var(--border);background:var(--bg-secondary);color:var(--text-secondary);cursor:pointer;align-items:center;gap:4px;padding:8px 14px;font-size:12px;font-weight:600;transition:background .15s,color .15s,box-shadow .15s;display:inline-flex}.action-btn:hover{background:var(--bg-hover);color:var(--text-primary)}.action-btn--primary{color:#cbdfff;color:color(display-p3 .811259 .873257 .992208);color:lab(88.2011% -1.89927 -18.3428);background:linear-gradient(135deg,#1d4587,#32326d);background:linear-gradient(135deg,color(display-p3 .153533 .2659 .511983),color(display-p3 .195291 .194916 .412031));background:linear-gradient(135deg,lab(29.3399% 5.63866 -42.0206),lab(23.3003% 14.2058 -35.0966));border-color:#33548c;border-color:color(display-p3 .229049 .327581 .533696);border-color:lab(35.4848% 1.84765 -35.3079)}.action-btn--primary:hover{background:linear-gradient(135deg,#2151a1,#3d3c86);background:linear-gradient(135deg,color(display-p3 .180134 .31482 .611657),color(display-p3 .239239 .236673 .50744));background:linear-gradient(135deg,lab(34.9279% 7.15981 -48.9835),lab(28.8006% 17.5515 -42.0715));box-shadow:0 2px 12px rgba(29,69,135,.3);box-shadow:0 2px 12px color(display-p3 .153533 .2659 .511983/.3);box-shadow:0 2px 12px lab(29.3399% 5.63866 -42.0206/.3)}.error-state,.empty-state{text-align:center;flex-direction:column;flex:1;justify-content:center;align-items:center;gap:8px;padding:32px;display:flex}.error-icon,.empty-icon{font-size:32px}.tooltip{z-index:100;border-radius:var(--radius-sm);background:var(--bg-tertiary);border:1px solid var(--border);color:var(--text-primary);pointer-events:none;opacity:0;max-width:200px;padding:6px 10px;font-size:11px;transition:opacity .15s;position:fixed}.tooltip.visible{opacity:1}@media (max-width:360px){.bazi-stem-chinese{font-size:22px}.bazi-branch-animal-emoji{font-size:18px}.bazi-hidden-char{font-size:13px}.bazi-pillar-header{padding:4px 6px;font-size:9px}#bazi-container{gap:10px;padding:10px}.bazi-pillars{gap:4px}}.chart-layer{opacity:1;transform-origin:310px 310px;animation:.5s cubic-bezier(.16,1,.3,1) both svgLayerAssemble;animation-delay:calc(var(--layer-index,0) * 80ms);transform:scale(1)}@keyframes svgLayerAssemble{0%{opacity:0;transform:scale(.97)}to{opacity:1;transform:scale(1)}}.floating-action-btn:after{content:"";background:linear-gradient(90deg,transparent,rgba(255,255,255,.2),transparent);width:50%;height:100%;animation:3s 2s infinite shimmer;position:absolute;top:0;left:-100%;transform:skew(-20deg)}@keyframes shimmer{0%{left:-100%}20%{left:200%}to{left:200%}}@keyframes slideUpFadeIn{0%{opacity:0;transform:translateY(20px)}to{opacity:1;transform:translateY(0)}}@keyframes slideUpFadeInPanel{0%{opacity:0;transform:translateY(12px)scale(.98)}to{opacity:1;transform:translateY(0)scale(1)}}
11
11
  /*$vite$:1*/</style>
12
- </head>
13
- <body>
14
- <div id="app">
15
- <div id="loading-state">
16
- <svg class="oe-loader" viewBox="0 0 100 100"><circle cx="50" cy="50" r="45"/></svg>
17
- <p>Computing Four Pillars…</p>
18
- </div>
19
- <div id="chart-status" class="chart-status" role="status" aria-live="polite"></div>
20
- <div id="bazi-container" hidden></div>
21
- <div id="tooltip" class="tooltip" role="tooltip"></div>
22
- </div>
23
-
12
+ </head>
13
+ <body>
14
+ <div id="app">
15
+ <div id="loading-state">
16
+ <svg class="oe-loader" viewBox="0 0 100 100"><circle cx="50" cy="50" r="45"/></svg>
17
+ <p>Computing Four Pillars…</p>
18
+ </div>
19
+ <div id="chart-status" class="chart-status" role="status" aria-live="polite"></div>
20
+ <div id="bazi-container" hidden></div>
21
+ <div id="tooltip" class="tooltip" aria-live="polite"></div>
22
+ </div>
23
+
24
24
  <script type="module">(function() {
25
25
  let e2 = document.createElement("link").relList;
26
26
  if (e2 && e2.supports && e2.supports("modulepreload")) return;
@@ -209,5 +209,5 @@ function A(e2) {
209
209
  }
210
210
  document.readyState === "loading" ? document.addEventListener("DOMContentLoaded", S) : S();
211
211
  </script>
212
- </body>
213
- </html>
212
+ </body>
213
+ </html>