@inflexa-ai/harness 0.16.0 → 0.18.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/agents/conversation-agent.d.ts +3 -0
- package/dist/agents/conversation-agent.js +5 -2
- package/dist/agents/report-builder.d.ts +2 -0
- package/dist/agents/report-builder.js +2 -0
- package/dist/agents/sandbox/shared.d.ts +3 -0
- package/dist/agents/sandbox/shared.js +5 -0
- package/dist/agents/sandbox/types.d.ts +1 -1
- package/dist/app/chat-turn.d.ts +2 -0
- package/dist/app/chat-turn.js +11 -2
- package/dist/app/spawn-report-session.d.ts +85 -0
- package/dist/app/spawn-report-session.js +83 -0
- package/dist/app/synthesize-run.d.ts +3 -0
- package/dist/app/synthesize-run.js +2 -1
- package/dist/citations/__fixtures__/resolver.d.ts +3 -0
- package/dist/citations/__fixtures__/resolver.js +9 -0
- package/dist/citations/aggregate.d.ts +3 -0
- package/dist/citations/aggregate.js +44 -0
- package/dist/citations/clients/arxiv.d.ts +6 -0
- package/dist/citations/clients/arxiv.js +34 -0
- package/dist/citations/clients/common.d.ts +11 -0
- package/dist/citations/clients/common.js +43 -0
- package/dist/citations/clients/crossref.d.ts +8 -0
- package/dist/citations/clients/crossref.js +98 -0
- package/dist/citations/clients/doi-registry.d.ts +3 -0
- package/dist/citations/clients/doi-registry.js +95 -0
- package/dist/citations/clients/pubmed.d.ts +6 -0
- package/dist/citations/clients/pubmed.js +83 -0
- package/dist/citations/clients/semantic-scholar.d.ts +6 -0
- package/dist/citations/clients/semantic-scholar.js +60 -0
- package/dist/citations/compare.d.ts +9 -0
- package/dist/citations/compare.js +72 -0
- package/dist/citations/match.d.ts +11 -0
- package/dist/citations/match.js +173 -0
- package/dist/citations/normalize.d.ts +31 -0
- package/dist/citations/normalize.js +187 -0
- package/dist/citations/plan.d.ts +2 -0
- package/dist/citations/plan.js +59 -0
- package/dist/citations/resolve.d.ts +28 -0
- package/dist/citations/resolve.js +238 -0
- package/dist/citations/types.d.ts +583 -0
- package/dist/citations/types.js +129 -0
- package/dist/contracts/chat-events.d.ts +9 -0
- package/dist/contracts/index.d.ts +6 -2
- package/dist/contracts/index.js +3 -1
- package/dist/contracts/organ-system.d.ts +49 -0
- package/dist/contracts/organ-system.js +67 -0
- package/dist/contracts/report-blocks.d.ts +564 -0
- package/dist/contracts/report-blocks.js +117 -0
- package/dist/contracts/report-reference.d.ts +459 -0
- package/dist/contracts/report-reference.js +256 -0
- package/dist/contracts/schemas/chat-events.d.ts +2 -0
- package/dist/contracts/schemas/chat-events.js +2 -0
- package/dist/contracts/schemas/chat-parts.d.ts +8 -8
- package/dist/contracts/severity.d.ts +15 -0
- package/dist/contracts/severity.js +10 -0
- package/dist/contracts/target-dossier.d.ts +8673 -12970
- package/dist/contracts/target-dossier.js +534 -591
- package/dist/data/safety-panel-schema.d.ts +18 -5
- package/dist/data/safety-panel-schema.js +4 -4
- package/dist/execution/artifact-metadata.js +1 -0
- package/dist/execution/report-runner.js +1 -3
- package/dist/execution/run-synthesis.d.ts +3 -0
- package/dist/execution/run-synthesis.js +3 -0
- package/dist/index.d.ts +12 -1
- package/dist/index.js +22 -0
- package/dist/lib/async-utils.d.ts +16 -4
- package/dist/lib/async-utils.js +20 -15
- package/dist/lib/cache.d.ts +8 -0
- package/dist/lib/cache.js +35 -0
- package/dist/lib/host-concurrency.js +1 -0
- package/dist/lib/rate-limit.d.ts +35 -0
- package/dist/lib/rate-limit.js +121 -0
- package/dist/literature/sources/arxiv.d.ts +22 -0
- package/dist/literature/sources/arxiv.js +103 -0
- package/dist/literature/sources/http.d.ts +28 -0
- package/dist/literature/sources/http.js +97 -0
- package/dist/literature/sources/pubmed.d.ts +68 -0
- package/dist/literature/sources/pubmed.js +266 -0
- package/dist/literature/sources/semantic-scholar.d.ts +24 -0
- package/dist/literature/sources/semantic-scholar.js +71 -0
- package/dist/loop/__fixtures__/scripted-provider.js +23 -2
- package/dist/loop/run-agent.js +58 -10
- package/dist/loop/tool-detail.d.ts +40 -0
- package/dist/loop/tool-detail.js +35 -3
- package/dist/loop/types.d.ts +13 -0
- package/dist/memory/thread-history.d.ts +10 -0
- package/dist/memory/thread-history.js +12 -1
- package/dist/memory/thread-store.d.ts +142 -25
- package/dist/memory/thread-store.js +228 -40
- package/dist/prompts/literature-reviewer.d.ts +1 -1
- package/dist/prompts/literature-reviewer.js +2 -0
- package/dist/prompts/target-assessment/briefs/executive-recommendation.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/executive-recommendation.js +5 -5
- package/dist/prompts/target-assessment/briefs/liability-bullets.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/liability-bullets.js +1 -1
- package/dist/prompts/target-assessment/briefs/target-organ-liabilities.d.ts +1 -1
- package/dist/prompts/target-assessment/briefs/target-organ-liabilities.js +11 -3
- package/dist/prompts/target-assessment/investigation/adversarial-critique.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/adversarial-critique.js +67 -0
- package/dist/prompts/target-assessment/investigation/claim-reverification.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/claim-reverification.js +49 -0
- package/dist/prompts/target-assessment/investigation/index.d.ts +3 -0
- package/dist/prompts/target-assessment/investigation/index.js +3 -0
- package/dist/prompts/target-assessment/investigation/mechanism-proposal.d.ts +1 -0
- package/dist/prompts/target-assessment/investigation/mechanism-proposal.js +49 -0
- package/dist/prompts/target-assessment/tox-voice/index.d.ts +1 -1
- package/dist/prompts/target-assessment/tox-voice/vocabulary.d.ts +8 -5
- package/dist/prompts/target-assessment/tox-voice/vocabulary.js +9 -20
- package/dist/providers/__fixtures__/session.d.ts +5 -4
- package/dist/providers/__fixtures__/session.js +4 -4
- package/dist/report-model/block-walk.d.ts +56 -0
- package/dist/report-model/block-walk.js +89 -0
- package/dist/report-model/draft-finish.d.ts +60 -0
- package/dist/report-model/draft-finish.js +56 -0
- package/dist/report-model/draft-operations.d.ts +125 -0
- package/dist/report-model/draft-operations.js +341 -0
- package/dist/report-model/draft-read.d.ts +59 -0
- package/dist/report-model/draft-read.js +109 -0
- package/dist/report-model/draft.d.ts +51 -0
- package/dist/report-model/draft.js +32 -0
- package/dist/report-model/fixture-resolver.d.ts +8 -0
- package/dist/report-model/fixture-resolver.js +252 -0
- package/dist/report-model/mint-snapshot.d.ts +30 -0
- package/dist/report-model/mint-snapshot.js +40 -0
- package/dist/report-model/reference-resolver.d.ts +113 -0
- package/dist/report-model/reference-resolver.js +60 -0
- package/dist/report-model/structural-validation.d.ts +22 -0
- package/dist/report-model/structural-validation.js +68 -0
- package/dist/report-model/validate.d.ts +40 -0
- package/dist/report-model/validate.js +80 -0
- package/dist/report-render/chart.d.ts +33 -0
- package/dist/report-render/chart.js +538 -0
- package/dist/report-render/page.d.ts +127 -0
- package/dist/report-render/page.js +307 -0
- package/dist/report-render/references.d.ts +32 -0
- package/dist/report-render/references.js +44 -0
- package/dist/report-render/render.d.ts +25 -0
- package/dist/report-render/render.js +125 -0
- package/dist/report-render/script-json.d.ts +13 -0
- package/dist/report-render/script-json.js +15 -0
- package/dist/report-render/types.d.ts +39 -0
- package/dist/report-render/types.js +10 -0
- package/dist/report-render/views/chart-view.d.ts +11 -0
- package/dist/report-render/views/chart-view.js +23 -0
- package/dist/report-render/views/page-view.d.ts +20 -0
- package/dist/report-render/views/page-view.js +49 -0
- package/dist/report-render/views/prose.d.ts +28 -0
- package/dist/report-render/views/prose.js +75 -0
- package/dist/report-render/views/references-view.d.ts +20 -0
- package/dist/report-render/views/references-view.js +64 -0
- package/dist/report-render/views/values.d.ts +42 -0
- package/dist/report-render/views/values.js +43 -0
- package/dist/runtime/assemble.d.ts +50 -4
- package/dist/runtime/assemble.js +47 -3
- package/dist/runtime/boot.d.ts +3 -3
- package/dist/runtime/boot.js +22 -5
- package/dist/runtime/dbos.d.ts +4 -4
- package/dist/state/artifacts.d.ts +16 -0
- package/dist/state/artifacts.js +18 -0
- package/dist/state/init.js +77 -1
- package/dist/state/purge-analysis.js +1 -0
- package/dist/state/report-versions.d.ts +132 -0
- package/dist/state/report-versions.js +194 -0
- package/dist/state/schema.d.ts +2 -2
- package/dist/tasks/data-profile.js +1 -0
- package/dist/tools/bio/chembl.js +1 -0
- package/dist/tools/bio/comptox.js +1 -0
- package/dist/tools/bio/drug-gene-interactions.js +1 -0
- package/dist/tools/bio/gene-disease-evidence.js +1 -0
- package/dist/tools/bio/gene-preclinical-profile.d.ts +1 -1
- package/dist/tools/bio/gene-preclinical-profile.js +1 -0
- package/dist/tools/bio/keys.d.ts +4 -2
- package/dist/tools/bio/keys.js +3 -2
- package/dist/tools/bio/lookup-annotation.js +1 -0
- package/dist/tools/bio/opentargets.d.ts +3 -3
- package/dist/tools/bio/opentargets.js +4 -3
- package/dist/tools/bio/pubchem.js +1 -0
- package/dist/tools/bio/pubmed.js +4 -4
- package/dist/tools/bio/search-clinical-trials.js +1 -0
- package/dist/tools/bio/search-faers.js +1 -0
- package/dist/tools/bio/search-geo-datasets.js +1 -0
- package/dist/tools/bio/search-interactions.js +1 -0
- package/dist/tools/bio/target-safety.d.ts +4 -6
- package/dist/tools/bio/target-safety.js +7 -6
- package/dist/tools/define-tool.d.ts +19 -12
- package/dist/tools/define-tool.js +4 -2
- package/dist/tools/display/show-user.js +1 -0
- package/dist/tools/iterate-report.js +2 -0
- package/dist/tools/lib/chembl-client.d.ts +2 -0
- package/dist/tools/lib/chembl-client.js +1 -0
- package/dist/tools/lib/monarch-client.d.ts +42 -0
- package/dist/tools/lib/monarch-client.js +100 -0
- package/dist/tools/lib/ncbi-utils.d.ts +3 -58
- package/dist/tools/lib/ncbi-utils.js +2 -286
- package/dist/tools/lib/pubmed-client.d.ts +5 -12
- package/dist/tools/lib/pubmed-client.js +20 -79
- package/dist/tools/report/build-report.js +1 -0
- package/dist/tools/report/mint-preview-url.js +1 -0
- package/dist/tools/report/preview-snapshot.js +1 -0
- package/dist/tools/report/submit-report.js +1 -0
- package/dist/tools/report/version-fs.js +4 -0
- package/dist/tools/report-authoring/authoring-tools.d.ts +134 -0
- package/dist/tools/report-authoring/authoring-tools.js +352 -0
- package/dist/tools/research/context7-docs.js +2 -0
- package/dist/tools/research/generate-analogy-report.js +3 -2
- package/dist/tools/research/generate-plan.js +3 -0
- package/dist/tools/research/index.d.ts +1 -0
- package/dist/tools/research/index.js +1 -0
- package/dist/tools/research/inspect-data-profile.js +4 -0
- package/dist/tools/research/inspect-run.d.ts +1 -1
- package/dist/tools/research/literature-reviewer.d.ts +3 -0
- package/dist/tools/research/literature-reviewer.js +3 -0
- package/dist/tools/research/resolve-citation.d.ts +3 -0
- package/dist/tools/research/resolve-citation.js +15 -0
- package/dist/tools/research/search-arxiv.d.ts +3 -19
- package/dist/tools/research/search-arxiv.js +10 -105
- package/dist/tools/research/search-github-repos.js +1 -0
- package/dist/tools/research/search-semantic-scholar.d.ts +8 -33
- package/dist/tools/research/search-semantic-scholar.js +30 -88
- package/dist/tools/sandbox/list-available-packages.js +23 -0
- package/dist/tools/sandbox/list-available-refs.js +37 -2
- package/dist/tools/sandbox/report-blocker.js +1 -0
- package/dist/tools/sandbox/skills.js +2 -0
- package/dist/tools/workspace/file-stat.js +1 -0
- package/dist/tools/workspace/show-file.js +2 -0
- package/dist/tools/workspace/show-plan.js +1 -0
- package/dist/workflows/execute-analysis.d.ts +8 -1
- package/dist/workflows/execute-analysis.js +6 -5
- package/dist/workflows/execute-target-assessment.d.ts +13 -2
- package/dist/workflows/execute-target-assessment.js +78 -16
- package/dist/workflows/sandbox-step.d.ts +5 -0
- package/dist/workflows/sandbox-step.js +1 -0
- package/dist/workflows/target-assessment/assemblers/index.d.ts +1 -1
- package/dist/workflows/target-assessment/assemblers/index.js +1 -1
- package/dist/workflows/target-assessment/assemblers/literature.d.ts +9 -27
- package/dist/workflows/target-assessment/assemblers/literature.js +32 -48
- package/dist/workflows/target-assessment/assemblers/orchestrator.d.ts +17 -9
- package/dist/workflows/target-assessment/assemblers/orchestrator.js +195 -85
- package/dist/workflows/target-assessment/assemblers/safety.d.ts +70 -48
- package/dist/workflows/target-assessment/assemblers/safety.js +125 -42
- package/dist/workflows/target-assessment/collectors/index.d.ts +5 -2
- package/dist/workflows/target-assessment/collectors/index.js +48 -5
- package/dist/workflows/target-assessment/coverage.d.ts +43 -27
- package/dist/workflows/target-assessment/coverage.js +33 -29
- package/dist/workflows/target-assessment/fanout/index.js +1 -0
- package/dist/workflows/target-assessment/investigation/index.d.ts +97 -0
- package/dist/workflows/target-assessment/investigation/index.js +419 -0
- package/dist/workflows/target-assessment/lib/approval-precedents.d.ts +26 -5
- package/dist/workflows/target-assessment/lib/approval-precedents.js +95 -29
- package/dist/workflows/target-assessment/lib/citation-validator.js +1 -1
- package/dist/workflows/target-assessment/lib/clinical-consequence-annotator.d.ts +2 -2
- package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.d.ts +2 -3
- package/dist/workflows/target-assessment/lib/clinical-evidence-attribution.js +0 -4
- package/dist/workflows/target-assessment/lib/compute-derived.d.ts +6 -5
- package/dist/workflows/target-assessment/lib/compute-derived.js +14 -5
- package/dist/workflows/target-assessment/lib/family-complex-supplement.d.ts +5 -6
- package/dist/workflows/target-assessment/lib/family-complex-supplement.js +2 -3
- package/dist/workflows/target-assessment/lib/fda-label-safety.d.ts +72 -0
- package/dist/workflows/target-assessment/lib/fda-label-safety.js +166 -0
- package/dist/workflows/target-assessment/lib/heterodimer-filter.d.ts +0 -9
- package/dist/workflows/target-assessment/lib/heterodimer-filter.js +0 -10
- package/dist/workflows/target-assessment/lib/hpo-organ-map.d.ts +34 -0
- package/dist/workflows/target-assessment/lib/hpo-organ-map.js +63 -0
- package/dist/workflows/target-assessment/lib/impc-organ-map.d.ts +19 -0
- package/dist/workflows/target-assessment/lib/impc-organ-map.js +39 -0
- package/dist/workflows/target-assessment/lib/meddra-organ-map.d.ts +24 -14
- package/dist/workflows/target-assessment/lib/meddra-organ-map.js +59 -47
- package/dist/workflows/target-assessment/lib/recommendation-sanitizer.js +1 -1
- package/dist/workflows/target-assessment/lib/regulatory-actions.d.ts +2 -3
- package/dist/workflows/target-assessment/lib/regulatory-actions.js +33 -4
- package/dist/workflows/target-assessment/lib/safety-corroboration.d.ts +44 -0
- package/dist/workflows/target-assessment/lib/safety-corroboration.js +273 -0
- package/dist/workflows/target-assessment/lib/target-identity-filter.d.ts +5 -7
- package/dist/workflows/target-assessment/lib/target-identity-filter.js +5 -7
- package/dist/workflows/target-assessment/metrics.d.ts +1 -6
- package/dist/workflows/target-assessment/metrics.js +1 -13
- package/dist/workflows/target-assessment/phase4-assemble.d.ts +2149 -809
- package/dist/workflows/target-assessment/phase4-assemble.js +19 -19
- package/dist/workflows/target-assessment/phase5-persist.d.ts +20 -21
- package/dist/workflows/target-assessment/phase5-persist.js +56 -173
- package/dist/workflows/target-assessment/schemas.d.ts +48 -3
- package/dist/workflows/target-assessment/schemas.js +19 -0
- package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.d.ts +1 -0
- package/dist/workflows/target-assessment/steps/fanout/polypharm-for-one-modulator-step.js +2 -0
- package/dist/workflows/target-assessment/steps/phase2-aggregate.d.ts +27 -63
- package/dist/workflows/target-assessment/steps/phase2-aggregate.js +0 -4
- package/dist/workflows/target-assessment/steps/phase3-aggregate.d.ts +28 -155
- package/dist/workflows/target-assessment/steps/phase3-aggregate.js +0 -6
- package/dist/workflows/target-assessment/steps/synthesis/diagnostics.d.ts +0 -6
- package/dist/workflows/target-assessment/steps/synthesis/diagnostics.js +0 -18
- package/dist/workflows/target-assessment/sub-workflows/per-modulator-polypharm.d.ts +1 -0
- package/dist/workflows/target-assessment/synthesis/index.d.ts +52 -5
- package/dist/workflows/target-assessment/synthesis/index.js +4 -1
- package/dist/workflows/target-assessment/synthesis-probes.d.ts +3 -3
- package/package.json +4 -1
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The mechanical validator of a report document.
|
|
3
|
+
*
|
|
4
|
+
* The schemas already carry the grammar: a strict object rejects a forbidden field, a binding is present
|
|
5
|
+
* by construction, and a metric value slot admits one scalar reference only. Thus a schema parse failure
|
|
6
|
+
* is the grammar rejection, and the validator does not re-implement it. The validator adds the three
|
|
7
|
+
* checks that a schema cannot make: it makes sure that each block id occurs one time only, it resolves
|
|
8
|
+
* each reference against the pinned evidence, and it warns about a free numeral in prose.
|
|
9
|
+
*/
|
|
10
|
+
import { ReportDocumentSchema } from "../contracts/report-blocks.js";
|
|
11
|
+
import { allWithConcurrency } from "../lib/async-utils.js";
|
|
12
|
+
import { walkBlocks } from "./block-walk.js";
|
|
13
|
+
import { columnsHeldByNoRow } from "./reference-resolver.js";
|
|
14
|
+
/**
|
|
15
|
+
* The cap on how many references resolve at the same time.
|
|
16
|
+
*
|
|
17
|
+
* A production resolver reads storage for each reference, and a large report holds hundreds of them. An
|
|
18
|
+
* unbounded fan-out would open one read for each reference at the same moment.
|
|
19
|
+
*/
|
|
20
|
+
const RESOLUTION_CONCURRENCY = 8;
|
|
21
|
+
/**
|
|
22
|
+
* Match each column that a chart encoding names against the table that its binding resolved to.
|
|
23
|
+
*
|
|
24
|
+
* The binding schema admits a table reference only, thus a resolved value of another type means that the
|
|
25
|
+
* bound resolver broke its own contract. That is reported and never ignored, because a chart with no
|
|
26
|
+
* table behind it renders nothing and a silent skip would pass it as grounded.
|
|
27
|
+
*/
|
|
28
|
+
function checkChartEncoding(entry, value) {
|
|
29
|
+
const encodingColumns = entry.encodingColumns;
|
|
30
|
+
if (encodingColumns === undefined || encodingColumns.length === 0) {
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
if (value.type !== "table") {
|
|
34
|
+
return { reference: entry.reference, reason: "locator-out-of-range", detail: `the chart binding resolved to a ${value.type} and not to a table` };
|
|
35
|
+
}
|
|
36
|
+
const absent = columnsHeldByNoRow(value.rows, encodingColumns);
|
|
37
|
+
if (absent.length === 0) {
|
|
38
|
+
return undefined;
|
|
39
|
+
}
|
|
40
|
+
return {
|
|
41
|
+
reference: entry.reference,
|
|
42
|
+
reason: "locator-out-of-range",
|
|
43
|
+
detail: `the encoding names column ${absent.join(", ")}, which the bound table does not hold`,
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
/** Resolve each reference, collect the schema conformance and the resolution outcomes, and warn on prose. */
|
|
47
|
+
export async function validateReport(document, snapshot, resolver) {
|
|
48
|
+
const parsed = ReportDocumentSchema.safeParse(document);
|
|
49
|
+
if (!parsed.success) {
|
|
50
|
+
const schemaIssues = parsed.error.issues.map((issue) => ({
|
|
51
|
+
path: issue.path.map((segment) => String(segment)).join("."),
|
|
52
|
+
message: issue.message,
|
|
53
|
+
}));
|
|
54
|
+
return { valid: false, schemaIssues, warnings: [] };
|
|
55
|
+
}
|
|
56
|
+
const { references, repeatedIds, warnings } = walkBlocks(parsed.data.sections);
|
|
57
|
+
const resolved = await allWithConcurrency(references.map((entry) => () => resolver.resolve(entry.reference, snapshot).then((result) => ({ entry, result }))), RESOLUTION_CONCURRENCY);
|
|
58
|
+
// Collect every failure. A reviewer must see each block that broke, thus the walk does not stop at
|
|
59
|
+
// the first unresolved reference. The `Err` channel carries the unresolved reference itself.
|
|
60
|
+
const resolutionFailures = [];
|
|
61
|
+
for (const { entry, result } of resolved) {
|
|
62
|
+
if (result.isErr()) {
|
|
63
|
+
resolutionFailures.push({ blockId: entry.blockId, failure: result.error });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
const encodingFailure = checkChartEncoding(entry, result.value);
|
|
67
|
+
if (encodingFailure !== undefined) {
|
|
68
|
+
resolutionFailures.push({ blockId: entry.blockId, failure: encodingFailure });
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (repeatedIds.length > 0 || resolutionFailures.length > 0) {
|
|
72
|
+
return {
|
|
73
|
+
valid: false,
|
|
74
|
+
...(repeatedIds.length > 0 ? { duplicateIds: repeatedIds } : {}),
|
|
75
|
+
...(resolutionFailures.length > 0 ? { resolutionFailures } : {}),
|
|
76
|
+
warnings,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return { valid: true, warnings };
|
|
80
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chart derivation of the report page.
|
|
3
|
+
*
|
|
4
|
+
* A chart block binds one whole-table artifact and a channel mapping. `deriveChartOption` turns the
|
|
5
|
+
* chart type, the encoding, and the resolved rows into one ECharts option object. The chart container
|
|
6
|
+
* markup lives beside this file, and it wraps the option that this derivation gives.
|
|
7
|
+
*
|
|
8
|
+
* The renderer computes no aggregate. Each plotted number stays traceable to one cell of the evidence
|
|
9
|
+
* artifact. A pie or a heatmap arrives one row per category or per cell, thus a repeated category or a
|
|
10
|
+
* repeated pair is a refusal and not a silent sum.
|
|
11
|
+
*
|
|
12
|
+
* The derivation is deterministic. Every object builds in one fixed key order, and the code reads no
|
|
13
|
+
* clock, no random value, and no locale. A string comparison uses the code-unit order (`<`) and never
|
|
14
|
+
* `localeCompare`, thus the same rows give the same bytes on every host.
|
|
15
|
+
*/
|
|
16
|
+
import { type Result } from "neverthrow";
|
|
17
|
+
import type { ChartBlock } from "../contracts/report-blocks.js";
|
|
18
|
+
import type { RenderProblem } from "./types.js";
|
|
19
|
+
/** One cell of a resolved row. A cell is one string or one number. */
|
|
20
|
+
type Cell = string | number;
|
|
21
|
+
/** One resolved row of the bound table, keyed by column name. */
|
|
22
|
+
export type ChartRow = Record<string, Cell>;
|
|
23
|
+
/** The derived option object, ready for `normalizeEchartSpec` and the inline JSON. */
|
|
24
|
+
export type EchartOption = Record<string, unknown>;
|
|
25
|
+
/**
|
|
26
|
+
* Derive the ECharts option for a chart block, then pass it through `normalizeEchartSpec`.
|
|
27
|
+
*
|
|
28
|
+
* The normalizer owns the bottom legend, the save action, and the axis-label discipline. Thus the
|
|
29
|
+
* derivation sets no `title`, no `legend`, no `toolbox`, and no explicit series color. The theme palette
|
|
30
|
+
* assigns the series colors by their order.
|
|
31
|
+
*/
|
|
32
|
+
export declare function deriveChartOption(block: ChartBlock, rows: readonly ChartRow[], columns?: readonly string[]): Result<EchartOption, RenderProblem>;
|
|
33
|
+
export {};
|
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The chart derivation of the report page.
|
|
3
|
+
*
|
|
4
|
+
* A chart block binds one whole-table artifact and a channel mapping. `deriveChartOption` turns the
|
|
5
|
+
* chart type, the encoding, and the resolved rows into one ECharts option object. The chart container
|
|
6
|
+
* markup lives beside this file, and it wraps the option that this derivation gives.
|
|
7
|
+
*
|
|
8
|
+
* The renderer computes no aggregate. Each plotted number stays traceable to one cell of the evidence
|
|
9
|
+
* artifact. A pie or a heatmap arrives one row per category or per cell, thus a repeated category or a
|
|
10
|
+
* repeated pair is a refusal and not a silent sum.
|
|
11
|
+
*
|
|
12
|
+
* The derivation is deterministic. Every object builds in one fixed key order, and the code reads no
|
|
13
|
+
* clock, no random value, and no locale. A string comparison uses the code-unit order (`<`) and never
|
|
14
|
+
* `localeCompare`, thus the same rows give the same bytes on every host.
|
|
15
|
+
*/
|
|
16
|
+
import { err, ok } from "neverthrow";
|
|
17
|
+
import { normalizeEchartSpec } from "../tools/display/normalize-echart-spec.js";
|
|
18
|
+
/**
|
|
19
|
+
* The ten-stop viridis ramp for a heatmap `visualMap`. The stops run dark to light, thus a continuous
|
|
20
|
+
* scale maps the low value to `#440154` and the high value to `#fde725`.
|
|
21
|
+
*/
|
|
22
|
+
const VIRIDIS = ["#440154", "#482777", "#3e4989", "#31688e", "#26828e", "#1f9e89", "#35b779", "#6ece58", "#b5de2b", "#fde725"];
|
|
23
|
+
/**
|
|
24
|
+
* Derive the ECharts option for a chart block, then pass it through `normalizeEchartSpec`.
|
|
25
|
+
*
|
|
26
|
+
* The normalizer owns the bottom legend, the save action, and the axis-label discipline. Thus the
|
|
27
|
+
* derivation sets no `title`, no `legend`, no `toolbox`, and no explicit series color. The theme palette
|
|
28
|
+
* assigns the series colors by their order.
|
|
29
|
+
*/
|
|
30
|
+
export function deriveChartOption(block, rows, columns) {
|
|
31
|
+
return deriveRaw(block, rows, columns).map((option) => normalizeEchartSpec(option, { title: block.title }));
|
|
32
|
+
}
|
|
33
|
+
/** Dispatch to the per-type derivation. Each type holds one fixed rule. */
|
|
34
|
+
function deriveRaw(block, rows, columns) {
|
|
35
|
+
switch (block.chartType) {
|
|
36
|
+
case "bar":
|
|
37
|
+
return deriveBar(block, rows, columns);
|
|
38
|
+
case "line":
|
|
39
|
+
return deriveLine(block, rows, columns);
|
|
40
|
+
case "scatter":
|
|
41
|
+
return deriveScatter(block, rows, columns);
|
|
42
|
+
case "histogram":
|
|
43
|
+
return deriveHistogram(block, rows, columns);
|
|
44
|
+
case "box":
|
|
45
|
+
return deriveBox(block, rows, columns);
|
|
46
|
+
case "heatmap":
|
|
47
|
+
return deriveHeatmap(block, rows, columns);
|
|
48
|
+
case "pie":
|
|
49
|
+
return derivePie(block, rows, columns);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
// ── The per-type derivations ────────────────────────────────────────────────
|
|
53
|
+
/** A category x axis, a value y axis, and one bar series per group with the `[x, y]` pairs. */
|
|
54
|
+
function deriveBar(block, rows, columns) {
|
|
55
|
+
const xResult = requireColumn(block, rows, columns, "x");
|
|
56
|
+
if (xResult.isErr())
|
|
57
|
+
return err(xResult.error);
|
|
58
|
+
const yResult = requireColumn(block, rows, columns, "y");
|
|
59
|
+
if (yResult.isErr())
|
|
60
|
+
return err(yResult.error);
|
|
61
|
+
const x = xResult.value;
|
|
62
|
+
const y = yResult.value;
|
|
63
|
+
const categories = firstAppearance(rows.map((row) => row[x]));
|
|
64
|
+
const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
|
|
65
|
+
type: "bar",
|
|
66
|
+
...(name !== undefined ? { name: String(name) } : {}),
|
|
67
|
+
barGap: 0,
|
|
68
|
+
data: groupRows.map((row) => [row[x], row[y]]),
|
|
69
|
+
}));
|
|
70
|
+
return ok({
|
|
71
|
+
xAxis: { type: "category", data: categories, name: x },
|
|
72
|
+
yAxis: { type: "value", name: y },
|
|
73
|
+
series,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
/** Per-column axis inference, and one line series per group whose data sorts by x. */
|
|
77
|
+
function deriveLine(block, rows, columns) {
|
|
78
|
+
const xResult = requireColumn(block, rows, columns, "x");
|
|
79
|
+
if (xResult.isErr())
|
|
80
|
+
return err(xResult.error);
|
|
81
|
+
const yResult = requireColumn(block, rows, columns, "y");
|
|
82
|
+
if (yResult.isErr())
|
|
83
|
+
return err(yResult.error);
|
|
84
|
+
const x = xResult.value;
|
|
85
|
+
const y = yResult.value;
|
|
86
|
+
const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
|
|
87
|
+
type: "line",
|
|
88
|
+
...(name !== undefined ? { name: String(name) } : {}),
|
|
89
|
+
showSymbol: false,
|
|
90
|
+
data: sortByX(groupRows.map((row) => [row[x], row[y]])),
|
|
91
|
+
}));
|
|
92
|
+
return ok({
|
|
93
|
+
xAxis: inferAxis(rows, x),
|
|
94
|
+
yAxis: inferAxis(rows, y),
|
|
95
|
+
series,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
/** The same axis inference as a line chart, with no sort and the large-render flags. */
|
|
99
|
+
function deriveScatter(block, rows, columns) {
|
|
100
|
+
const xResult = requireColumn(block, rows, columns, "x");
|
|
101
|
+
if (xResult.isErr())
|
|
102
|
+
return err(xResult.error);
|
|
103
|
+
const yResult = requireColumn(block, rows, columns, "y");
|
|
104
|
+
if (yResult.isErr())
|
|
105
|
+
return err(yResult.error);
|
|
106
|
+
const x = xResult.value;
|
|
107
|
+
const y = yResult.value;
|
|
108
|
+
const series = groupedSeries(rows, block.encoding.group, (groupRows, name) => ({
|
|
109
|
+
type: "scatter",
|
|
110
|
+
...(name !== undefined ? { name: String(name) } : {}),
|
|
111
|
+
large: true,
|
|
112
|
+
largeThreshold: 2000,
|
|
113
|
+
data: groupRows.map((row) => [row[x], row[y]]),
|
|
114
|
+
}));
|
|
115
|
+
return ok({
|
|
116
|
+
xAxis: inferAxis(rows, x),
|
|
117
|
+
yAxis: inferAxis(rows, y),
|
|
118
|
+
series,
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
/** Equal-width bins over the global range. Each group shares the same edges. */
|
|
122
|
+
function deriveHistogram(block, rows, columns) {
|
|
123
|
+
const xResult = requireColumn(block, rows, columns, "x");
|
|
124
|
+
if (xResult.isErr())
|
|
125
|
+
return err(xResult.error);
|
|
126
|
+
const x = xResult.value;
|
|
127
|
+
const groupCol = block.encoding.group;
|
|
128
|
+
if (rows.length === 0) {
|
|
129
|
+
return ok({
|
|
130
|
+
xAxis: { type: "value", scale: true },
|
|
131
|
+
yAxis: { type: "value", name: "Count" },
|
|
132
|
+
series: [{ type: "bar", barWidth: "99%", data: [] }],
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
const values = numericColumn(rows, x);
|
|
136
|
+
if (values.length === 0) {
|
|
137
|
+
return err(problem(block.id, `The histogram column "${x}" holds no numeric value.`));
|
|
138
|
+
}
|
|
139
|
+
const edges = histogramEdges(values);
|
|
140
|
+
let series;
|
|
141
|
+
if (groupCol === undefined) {
|
|
142
|
+
series = [histogramSeries(values, edges, undefined)];
|
|
143
|
+
}
|
|
144
|
+
else {
|
|
145
|
+
series = [];
|
|
146
|
+
for (const name of firstAppearance(rows.map((row) => row[groupCol]))) {
|
|
147
|
+
const groupValues = numericColumn(rows.filter((row) => row[groupCol] === name), x);
|
|
148
|
+
series.push(histogramSeries(groupValues, edges, name));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
return ok({
|
|
152
|
+
xAxis: { type: "value", scale: true },
|
|
153
|
+
yAxis: { type: "value", name: "Count" },
|
|
154
|
+
series,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
/** A five-number summary per category, plus a paired scatter series for the outliers. */
|
|
158
|
+
function deriveBox(block, rows, columns) {
|
|
159
|
+
const xResult = requireColumn(block, rows, columns, "x");
|
|
160
|
+
if (xResult.isErr())
|
|
161
|
+
return err(xResult.error);
|
|
162
|
+
const yResult = requireColumn(block, rows, columns, "y");
|
|
163
|
+
if (yResult.isErr())
|
|
164
|
+
return err(yResult.error);
|
|
165
|
+
const x = xResult.value;
|
|
166
|
+
const y = yResult.value;
|
|
167
|
+
const categories = firstAppearance(rows.map((row) => row[x]));
|
|
168
|
+
const groups = splitGroups(rows, block.encoding.group);
|
|
169
|
+
const series = [];
|
|
170
|
+
for (const group of groups) {
|
|
171
|
+
const boxData = [];
|
|
172
|
+
const outliers = [];
|
|
173
|
+
for (let index = 0; index < categories.length; index++) {
|
|
174
|
+
const values = numericColumn(group.rows.filter((row) => row[x] === categories[index]), y);
|
|
175
|
+
// A category with fewer than five values renders as an empty box.
|
|
176
|
+
if (values.length < 5) {
|
|
177
|
+
boxData.push("-");
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
const summary = boxSummary(values);
|
|
181
|
+
boxData.push(summary.box);
|
|
182
|
+
for (const outlier of summary.outliers) {
|
|
183
|
+
outliers.push([index, outlier]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
series.push({
|
|
187
|
+
type: "boxplot",
|
|
188
|
+
...(group.name !== undefined ? { name: String(group.name) } : {}),
|
|
189
|
+
data: boxData,
|
|
190
|
+
});
|
|
191
|
+
// The outlier scatter pairs with its box series, thus it only appears when an outlier exists.
|
|
192
|
+
if (outliers.length > 0) {
|
|
193
|
+
series.push({
|
|
194
|
+
type: "scatter",
|
|
195
|
+
...(group.name !== undefined ? { name: String(group.name) } : {}),
|
|
196
|
+
symbolSize: 4,
|
|
197
|
+
data: outliers,
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
return ok({
|
|
202
|
+
xAxis: { type: "category", data: categories, name: x },
|
|
203
|
+
yAxis: { type: "value", name: y },
|
|
204
|
+
series,
|
|
205
|
+
});
|
|
206
|
+
}
|
|
207
|
+
/** A dense grid over every pair of x and y categories, with a viridis `visualMap`. */
|
|
208
|
+
function deriveHeatmap(block, rows, columns) {
|
|
209
|
+
const xResult = requireColumn(block, rows, columns, "x");
|
|
210
|
+
if (xResult.isErr())
|
|
211
|
+
return err(xResult.error);
|
|
212
|
+
const yResult = requireColumn(block, rows, columns, "y");
|
|
213
|
+
if (yResult.isErr())
|
|
214
|
+
return err(yResult.error);
|
|
215
|
+
const valueResult = requireColumn(block, rows, columns, "value");
|
|
216
|
+
if (valueResult.isErr())
|
|
217
|
+
return err(valueResult.error);
|
|
218
|
+
const x = xResult.value;
|
|
219
|
+
const y = yResult.value;
|
|
220
|
+
const valueColumn = valueResult.value;
|
|
221
|
+
const xCategories = firstAppearance(rows.map((row) => row[x]));
|
|
222
|
+
const yCategories = firstAppearance(rows.map((row) => row[y]));
|
|
223
|
+
const cells = new Map();
|
|
224
|
+
for (const row of rows) {
|
|
225
|
+
const key = pairKey(row[x], row[y]);
|
|
226
|
+
if (cells.has(key)) {
|
|
227
|
+
return err(problem(block.id, `The heatmap holds the pair (${String(row[x])}, ${String(row[y])}) more than one time.`));
|
|
228
|
+
}
|
|
229
|
+
cells.set(key, toNumber(row[valueColumn]));
|
|
230
|
+
}
|
|
231
|
+
const data = [];
|
|
232
|
+
const finite = [];
|
|
233
|
+
for (let xi = 0; xi < xCategories.length; xi++) {
|
|
234
|
+
for (let yi = 0; yi < yCategories.length; yi++) {
|
|
235
|
+
const stored = cells.get(pairKey(xCategories[xi], yCategories[yi]));
|
|
236
|
+
const value = stored === undefined ? null : stored;
|
|
237
|
+
data.push([xi, yi, value]);
|
|
238
|
+
if (value !== null)
|
|
239
|
+
finite.push(value);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
const min = finite.length > 0 ? Math.min(...finite) : 0;
|
|
243
|
+
const max = finite.length > 0 ? Math.max(...finite) : 1;
|
|
244
|
+
return ok({
|
|
245
|
+
xAxis: { type: "category", data: xCategories.map(String), name: x, splitArea: { show: true } },
|
|
246
|
+
yAxis: { type: "category", data: yCategories.map(String), name: y, splitArea: { show: true } },
|
|
247
|
+
visualMap: {
|
|
248
|
+
type: "continuous",
|
|
249
|
+
min,
|
|
250
|
+
max,
|
|
251
|
+
calculable: true,
|
|
252
|
+
orient: "horizontal",
|
|
253
|
+
left: "center",
|
|
254
|
+
bottom: 0,
|
|
255
|
+
inRange: { color: [...VIRIDIS] },
|
|
256
|
+
},
|
|
257
|
+
series: [{ type: "heatmap", data }],
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
/** One slice per group category, in first-appearance order. A repeated category refuses. */
|
|
261
|
+
function derivePie(block, rows, columns) {
|
|
262
|
+
const groupResult = requireColumn(block, rows, columns, "group");
|
|
263
|
+
if (groupResult.isErr())
|
|
264
|
+
return err(groupResult.error);
|
|
265
|
+
const valueResult = requireColumn(block, rows, columns, "value");
|
|
266
|
+
if (valueResult.isErr())
|
|
267
|
+
return err(valueResult.error);
|
|
268
|
+
const groupColumn = groupResult.value;
|
|
269
|
+
const valueColumn = valueResult.value;
|
|
270
|
+
const seen = new Set();
|
|
271
|
+
const data = [];
|
|
272
|
+
for (const row of rows) {
|
|
273
|
+
const name = String(row[groupColumn]);
|
|
274
|
+
if (seen.has(name)) {
|
|
275
|
+
return err(problem(block.id, `The pie holds the category "${name}" more than one time.`));
|
|
276
|
+
}
|
|
277
|
+
seen.add(name);
|
|
278
|
+
const numeric = toNumber(row[valueColumn]);
|
|
279
|
+
data.push({ name, value: numeric === null ? row[valueColumn] : numeric });
|
|
280
|
+
}
|
|
281
|
+
return ok({
|
|
282
|
+
series: [{ type: "pie", radius: "55%", data }],
|
|
283
|
+
});
|
|
284
|
+
}
|
|
285
|
+
// ── The shared building blocks ──────────────────────────────────────────────
|
|
286
|
+
/** A typed `invalid-chart-input` problem that names the block and the cause. */
|
|
287
|
+
function problem(blockId, detail) {
|
|
288
|
+
return { blockId, kind: "invalid-chart-input", detail };
|
|
289
|
+
}
|
|
290
|
+
/**
|
|
291
|
+
* Resolve one demanded channel to its column name.
|
|
292
|
+
*
|
|
293
|
+
* A channel that the encoding omits is a refusal. A named column that no row holds is a refusal too.
|
|
294
|
+
* A zero-row chart skips the row check, thus it renders an empty container.
|
|
295
|
+
*/
|
|
296
|
+
function requireColumn(block, rows, columns, channel) {
|
|
297
|
+
const column = block.encoding[channel];
|
|
298
|
+
if (column === undefined) {
|
|
299
|
+
return err(problem(block.id, `The ${block.chartType} chart needs a column for the "${channel}" channel.`));
|
|
300
|
+
}
|
|
301
|
+
if (rows.length > 0 && !columnPresent(column, rows, columns)) {
|
|
302
|
+
return err(problem(block.id, `The column "${column}" is absent from every row.`));
|
|
303
|
+
}
|
|
304
|
+
return ok(column);
|
|
305
|
+
}
|
|
306
|
+
/** True when the column exists in the declared header, or in one row at least. */
|
|
307
|
+
function columnPresent(column, rows, columns) {
|
|
308
|
+
if (columns !== undefined && columns.includes(column))
|
|
309
|
+
return true;
|
|
310
|
+
for (const row of rows) {
|
|
311
|
+
if (Object.prototype.hasOwnProperty.call(row, column))
|
|
312
|
+
return true;
|
|
313
|
+
}
|
|
314
|
+
return false;
|
|
315
|
+
}
|
|
316
|
+
/** Convert one cell to a finite number, or `null` when it is not numeric. */
|
|
317
|
+
function toNumber(cell) {
|
|
318
|
+
if (typeof cell === "number")
|
|
319
|
+
return Number.isFinite(cell) ? cell : null;
|
|
320
|
+
if (typeof cell === "string") {
|
|
321
|
+
const trimmed = cell.trim();
|
|
322
|
+
if (trimmed === "")
|
|
323
|
+
return null;
|
|
324
|
+
const parsed = Number(trimmed);
|
|
325
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
326
|
+
}
|
|
327
|
+
return null;
|
|
328
|
+
}
|
|
329
|
+
/** True when the cell is a string that does not represent a finite number. */
|
|
330
|
+
function isNonNumericString(cell) {
|
|
331
|
+
return typeof cell === "string" && toNumber(cell) === null;
|
|
332
|
+
}
|
|
333
|
+
/** The distinct values in first-appearance order. */
|
|
334
|
+
function firstAppearance(values) {
|
|
335
|
+
const seen = new Set();
|
|
336
|
+
const order = [];
|
|
337
|
+
for (const value of values) {
|
|
338
|
+
if (!seen.has(value)) {
|
|
339
|
+
seen.add(value);
|
|
340
|
+
order.push(value);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
return order;
|
|
344
|
+
}
|
|
345
|
+
/** The numeric cells of one column, in row order. A non-numeric cell drops. */
|
|
346
|
+
function numericColumn(rows, column) {
|
|
347
|
+
const values = [];
|
|
348
|
+
for (const row of rows) {
|
|
349
|
+
const value = toNumber(row[column]);
|
|
350
|
+
if (value !== null)
|
|
351
|
+
values.push(value);
|
|
352
|
+
}
|
|
353
|
+
return values;
|
|
354
|
+
}
|
|
355
|
+
/**
|
|
356
|
+
* The axis for a line or a scatter column. A column with any non-numeric string cell is a category axis
|
|
357
|
+
* with its distinct values as strings. Any other column is a value axis with `scale: true`.
|
|
358
|
+
*/
|
|
359
|
+
function inferAxis(rows, column) {
|
|
360
|
+
if (rows.some((row) => isNonNumericString(row[column]))) {
|
|
361
|
+
return { type: "category", data: firstAppearance(rows.map((row) => row[column])).map(String), name: column };
|
|
362
|
+
}
|
|
363
|
+
return { type: "value", scale: true, name: column };
|
|
364
|
+
}
|
|
365
|
+
/**
|
|
366
|
+
* Build one series per group, in first-appearance order of the group values. A chart with no group
|
|
367
|
+
* column gets one series over every row.
|
|
368
|
+
*/
|
|
369
|
+
function groupedSeries(rows, groupCol, make) {
|
|
370
|
+
if (groupCol === undefined) {
|
|
371
|
+
return [make(rows, undefined)];
|
|
372
|
+
}
|
|
373
|
+
const series = [];
|
|
374
|
+
for (const name of firstAppearance(rows.map((row) => row[groupCol]))) {
|
|
375
|
+
series.push(make(rows.filter((row) => row[groupCol] === name), name));
|
|
376
|
+
}
|
|
377
|
+
return series;
|
|
378
|
+
}
|
|
379
|
+
/** The row groups in first-appearance order, or one unnamed group when there is no group column. */
|
|
380
|
+
function splitGroups(rows, groupCol) {
|
|
381
|
+
if (groupCol === undefined) {
|
|
382
|
+
return [{ name: undefined, rows }];
|
|
383
|
+
}
|
|
384
|
+
return firstAppearance(rows.map((row) => row[groupCol])).map((name) => ({
|
|
385
|
+
name,
|
|
386
|
+
rows: rows.filter((row) => row[groupCol] === name),
|
|
387
|
+
}));
|
|
388
|
+
}
|
|
389
|
+
/** Sort `[x, y]` pairs by x. Two numbers compare numerically, and any other pair compares as text. */
|
|
390
|
+
function sortByX(pairs) {
|
|
391
|
+
return [...pairs].sort((a, b) => compareCell(a[0], b[0]));
|
|
392
|
+
}
|
|
393
|
+
/**
|
|
394
|
+
* Compare two cells for a sort by x. Two numbers compare by their difference. Any other pair compares
|
|
395
|
+
* by the code-unit order of the string form. The comparison never calls `localeCompare`, thus the order
|
|
396
|
+
* stays the same on every host.
|
|
397
|
+
*/
|
|
398
|
+
function compareCell(a, b) {
|
|
399
|
+
if (typeof a === "number" && typeof b === "number") {
|
|
400
|
+
return a - b;
|
|
401
|
+
}
|
|
402
|
+
const left = String(a);
|
|
403
|
+
const right = String(b);
|
|
404
|
+
if (left < right)
|
|
405
|
+
return -1;
|
|
406
|
+
if (left > right)
|
|
407
|
+
return 1;
|
|
408
|
+
return 0;
|
|
409
|
+
}
|
|
410
|
+
/**
|
|
411
|
+
* A stable key for one `(x, y)` pair. The key carries the type of each cell, thus the number `1` and the
|
|
412
|
+
* string `"1"` are different pairs and a false collision is impossible.
|
|
413
|
+
*/
|
|
414
|
+
function pairKey(xCell, yCell) {
|
|
415
|
+
return `${typeof xCell}:${String(xCell)}${typeof yCell}:${String(yCell)}`;
|
|
416
|
+
}
|
|
417
|
+
// ── The histogram math ──────────────────────────────────────────────────────
|
|
418
|
+
/**
|
|
419
|
+
* The bin edges over the global range. The bin count is the larger of the Sturges count and the
|
|
420
|
+
* Freedman-Diaconis count. One bin applies when the range is zero.
|
|
421
|
+
*/
|
|
422
|
+
function histogramEdges(values) {
|
|
423
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
424
|
+
const min = sorted[0];
|
|
425
|
+
const max = sorted[sorted.length - 1];
|
|
426
|
+
if (max === min) {
|
|
427
|
+
return [min, max];
|
|
428
|
+
}
|
|
429
|
+
const count = autoBinCount(sorted, min, max);
|
|
430
|
+
const width = (max - min) / count;
|
|
431
|
+
const edges = [];
|
|
432
|
+
for (let i = 0; i <= count; i++) {
|
|
433
|
+
edges.push(min + i * width);
|
|
434
|
+
}
|
|
435
|
+
// Pin the last edge to the exact max, thus a float drift cannot drop the top value out of the range.
|
|
436
|
+
edges[count] = max;
|
|
437
|
+
return edges;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* The automatic bin count: `max(Sturges, Freedman-Diaconis)`. Sturges is `ceil(log2(n)) + 1`.
|
|
441
|
+
* Freedman-Diaconis derives the width `2 * IQR / n^(1/3)` and the count `ceil((max - min) / width)`. When
|
|
442
|
+
* the IQR is zero, the Sturges count applies alone.
|
|
443
|
+
*/
|
|
444
|
+
function autoBinCount(sorted, min, max) {
|
|
445
|
+
const n = sorted.length;
|
|
446
|
+
const sturges = Math.ceil(Math.log2(n)) + 1;
|
|
447
|
+
const iqr = quantileType7(sorted, 0.75) - quantileType7(sorted, 0.25);
|
|
448
|
+
if (iqr === 0) {
|
|
449
|
+
return Math.max(1, sturges);
|
|
450
|
+
}
|
|
451
|
+
const width = (2 * iqr) / Math.cbrt(n);
|
|
452
|
+
const fd = Math.ceil((max - min) / width);
|
|
453
|
+
return Math.max(1, sturges, fd);
|
|
454
|
+
}
|
|
455
|
+
/** The bin counts for the values against the edges. The max value falls in the last bin. */
|
|
456
|
+
function binCounts(values, edges) {
|
|
457
|
+
const bins = edges.length - 1;
|
|
458
|
+
const counts = new Array(bins).fill(0);
|
|
459
|
+
const min = edges[0];
|
|
460
|
+
const width = (edges[bins] - min) / bins;
|
|
461
|
+
for (const value of values) {
|
|
462
|
+
let index = width === 0 ? 0 : Math.floor((value - min) / width);
|
|
463
|
+
if (index < 0)
|
|
464
|
+
index = 0;
|
|
465
|
+
if (index >= bins)
|
|
466
|
+
index = bins - 1;
|
|
467
|
+
counts[index] += 1;
|
|
468
|
+
}
|
|
469
|
+
return counts;
|
|
470
|
+
}
|
|
471
|
+
/** One histogram series. Each bar sits at the bin midpoint. A grouped series overlaps its siblings. */
|
|
472
|
+
function histogramSeries(values, edges, name) {
|
|
473
|
+
const counts = binCounts(values, edges);
|
|
474
|
+
const data = [];
|
|
475
|
+
for (let i = 0; i < counts.length; i++) {
|
|
476
|
+
data.push([(edges[i] + edges[i + 1]) / 2, counts[i]]);
|
|
477
|
+
}
|
|
478
|
+
if (name === undefined) {
|
|
479
|
+
return { type: "bar", barWidth: "99%", data };
|
|
480
|
+
}
|
|
481
|
+
return {
|
|
482
|
+
type: "bar",
|
|
483
|
+
name: String(name),
|
|
484
|
+
barWidth: "99%",
|
|
485
|
+
barGap: "-100%",
|
|
486
|
+
itemStyle: { opacity: 0.7 },
|
|
487
|
+
data,
|
|
488
|
+
};
|
|
489
|
+
}
|
|
490
|
+
// ── The box math ────────────────────────────────────────────────────────────
|
|
491
|
+
/**
|
|
492
|
+
* The five-number summary of one category.
|
|
493
|
+
*
|
|
494
|
+
* The quartiles are type-7 quantiles. The whiskers are the Tukey fences at `1.5 * IQR`, clamped to the
|
|
495
|
+
* nearest data value inside each fence. A value outside a fence is an outlier.
|
|
496
|
+
*/
|
|
497
|
+
function boxSummary(values) {
|
|
498
|
+
const sorted = [...values].sort((a, b) => a - b);
|
|
499
|
+
const q1 = quantileType7(sorted, 0.25);
|
|
500
|
+
const median = quantileType7(sorted, 0.5);
|
|
501
|
+
const q3 = quantileType7(sorted, 0.75);
|
|
502
|
+
const iqr = q3 - q1;
|
|
503
|
+
const lowerFence = q1 - 1.5 * iqr;
|
|
504
|
+
const upperFence = q3 + 1.5 * iqr;
|
|
505
|
+
let whiskerLow = q1;
|
|
506
|
+
let whiskerHigh = q3;
|
|
507
|
+
let lowSet = false;
|
|
508
|
+
const outliers = [];
|
|
509
|
+
for (const value of sorted) {
|
|
510
|
+
if (value < lowerFence || value > upperFence) {
|
|
511
|
+
outliers.push(value);
|
|
512
|
+
continue;
|
|
513
|
+
}
|
|
514
|
+
// The values arrive in ascending order. Thus the first in-fence value is the low whisker, and the
|
|
515
|
+
// last in-fence value is the high whisker.
|
|
516
|
+
if (!lowSet) {
|
|
517
|
+
whiskerLow = value;
|
|
518
|
+
lowSet = true;
|
|
519
|
+
}
|
|
520
|
+
whiskerHigh = value;
|
|
521
|
+
}
|
|
522
|
+
return { box: [whiskerLow, q1, median, q3, whiskerHigh], outliers };
|
|
523
|
+
}
|
|
524
|
+
/**
|
|
525
|
+
* The type-7 quantile of a sorted array. The index is `p * (n - 1)`, and the value interpolates
|
|
526
|
+
* linearly between the `floor` and the `ceil` of that index.
|
|
527
|
+
*/
|
|
528
|
+
function quantileType7(sorted, p) {
|
|
529
|
+
const n = sorted.length;
|
|
530
|
+
if (n === 1)
|
|
531
|
+
return sorted[0];
|
|
532
|
+
const index = p * (n - 1);
|
|
533
|
+
const low = Math.floor(index);
|
|
534
|
+
const high = Math.ceil(index);
|
|
535
|
+
if (low === high)
|
|
536
|
+
return sorted[low];
|
|
537
|
+
return sorted[low] + (index - low) * (sorted[high] - sorted[low]);
|
|
538
|
+
}
|