155 lines
4.9 KiB
TypeScript
155 lines
4.9 KiB
TypeScript
/**
|
|
* Nearest divisional-chart change, from window-scan transitions only.
|
|
* Question order is a switch and stays off unless the env flag is "on".
|
|
*/
|
|
|
|
export const CHART_LAYERS = ["d9", "d10", "d4", "d5", "d24", "d7", "d12", "d2", "d11", "d30", "d1"] as const;
|
|
|
|
const LAYER_DOMAIN: Readonly<Record<string, string>> = {
|
|
d9: "relationship",
|
|
d10: "career",
|
|
d4: "relocation",
|
|
d5: "education",
|
|
d24: "education",
|
|
d7: "family",
|
|
d12: "family",
|
|
d2: "finance",
|
|
d11: "finance",
|
|
d30: "health_pressure",
|
|
};
|
|
|
|
const LAYER_TOPIC: Readonly<Record<string, string>> = {
|
|
d9: "婚恋",
|
|
d10: "工作",
|
|
d4: "住处",
|
|
d5: "学业",
|
|
d24: "学业",
|
|
d7: "子女",
|
|
d12: "父母",
|
|
d2: "钱",
|
|
d11: "钱",
|
|
d30: "身体",
|
|
};
|
|
|
|
/** Not adopted. The 77-case gate was not cleared, so the switch stays off. */
|
|
export const NEAREST_BOUNDARY_PROBE_THRESHOLD_MINUTES = 5;
|
|
|
|
export type BoundaryLayer = Readonly<{
|
|
layer: string;
|
|
fromSign: string;
|
|
toSign: string;
|
|
topic: string | null;
|
|
domain: string | null;
|
|
}>;
|
|
|
|
export type NearestBoundary = Readonly<{
|
|
found: boolean;
|
|
reason: "unavailable" | "no_transition" | null;
|
|
at: string | null;
|
|
distanceMinutes: number | null;
|
|
layers: readonly BoundaryLayer[];
|
|
topics: readonly string[];
|
|
domains: readonly string[];
|
|
}>;
|
|
|
|
const EMPTY: NearestBoundary = {
|
|
found: false, reason: "unavailable", at: null, distanceMinutes: null, layers: [], topics: [], domains: [],
|
|
};
|
|
|
|
function clock(value: string | null | undefined): number | null {
|
|
const raw = value?.trim().slice(0, 5) ?? "";
|
|
if (!/^(?:[01]\d|2[0-3]):[0-5]\d$/.test(raw)) return null;
|
|
return Number(raw.slice(0, 2)) * 60 + Number(raw.slice(3, 5));
|
|
}
|
|
|
|
function distance(left: number, right: number): number {
|
|
const span = Math.abs(left - right);
|
|
return Math.min(span, 1440 - span);
|
|
}
|
|
|
|
export function nearestBoundaryFromTransitions(
|
|
transitions: readonly Readonly<{ layer?: string; at?: string; from_sign?: string; to_sign?: string }>[] | null | undefined,
|
|
recordedTime: string | null | undefined,
|
|
): NearestBoundary {
|
|
const recorded = clock(recordedTime);
|
|
if (recorded === null || !transitions) return EMPTY;
|
|
const changes: { at: number; layer: string; fromSign: string; toSign: string }[] = [];
|
|
for (const row of transitions) {
|
|
const layer = row.layer ?? "";
|
|
if (!CHART_LAYERS.includes(layer as (typeof CHART_LAYERS)[number])) continue;
|
|
const before = row.from_sign;
|
|
const after = row.to_sign;
|
|
if (!before || !after || before === after) continue;
|
|
const at = clock(row.at);
|
|
if (at === null) continue;
|
|
changes.push({ at, layer, fromSign: before, toSign: after });
|
|
}
|
|
if (!changes.length) return { ...EMPTY, reason: "no_transition" };
|
|
const bestAt = changes.reduce((best, row) => {
|
|
const next = distance(row.at, recorded);
|
|
const current = distance(best, recorded);
|
|
return next < current || (next === current && row.at < best) ? row.at : best;
|
|
}, changes[0]!.at);
|
|
const layers: BoundaryLayer[] = [];
|
|
const seen = new Set<string>();
|
|
for (const layer of CHART_LAYERS) {
|
|
const row = changes.find((item) => item.at === bestAt && item.layer === layer);
|
|
if (!row || seen.has(layer)) continue;
|
|
seen.add(layer);
|
|
layers.push({
|
|
layer,
|
|
fromSign: row.fromSign,
|
|
toSign: row.toSign,
|
|
topic: LAYER_TOPIC[layer] ?? null,
|
|
domain: LAYER_DOMAIN[layer] ?? null,
|
|
});
|
|
}
|
|
const topics = topicsForLayers(layers.map((row) => row.layer));
|
|
const domains: string[] = [];
|
|
for (const row of layers) {
|
|
if (row.domain && !domains.includes(row.domain)) domains.push(row.domain);
|
|
}
|
|
return {
|
|
found: true,
|
|
reason: null,
|
|
at: `${String(Math.floor(bestAt / 60)).padStart(2, "0")}:${String(bestAt % 60).padStart(2, "0")}`,
|
|
distanceMinutes: distance(bestAt, recorded),
|
|
layers,
|
|
topics,
|
|
domains,
|
|
};
|
|
}
|
|
|
|
/** Spoken topics, in chart order. Unknown layers and D1 add nothing. */
|
|
export function topicsForLayers(layers: readonly string[]): string[] {
|
|
const topics: string[] = [];
|
|
for (const layer of CHART_LAYERS) {
|
|
if (!layers.includes(layer)) continue;
|
|
const topic = LAYER_TOPIC[layer];
|
|
if (topic && !topics.includes(topic)) topics.push(topic);
|
|
}
|
|
return topics;
|
|
}
|
|
|
|
export function nearestBoundaryProbesEnabled(flag: string | undefined = process.env.RECTIFICATION_NEAREST_BOUNDARY_PROBES): boolean {
|
|
return flag === "on";
|
|
}
|
|
|
|
/** Boundary-domain questions first; everything else keeps its incoming order. */
|
|
export function orderByBoundaryDomains<T>(
|
|
probes: readonly T[],
|
|
domains: readonly string[],
|
|
domainOf: (probe: T) => string,
|
|
): readonly T[] {
|
|
if (!domains.length || probes.length < 2) return probes;
|
|
const wanted = new Set(domains);
|
|
const first: T[] = [];
|
|
const rest: T[] = [];
|
|
for (const probe of probes) {
|
|
const domain = domainOf(probe);
|
|
(domain && wanted.has(domain) ? first : rest).push(probe);
|
|
}
|
|
if (!first.length || !rest.length) return probes;
|
|
return [...first, ...rest];
|
|
}
|