diff --git a/.github/workflows/python-package.yml b/.github/workflows/python-package.yml index af257bb4..82ddad50 100644 --- a/.github/workflows/python-package.yml +++ b/.github/workflows/python-package.yml @@ -52,7 +52,7 @@ jobs: prefix = "rtichoke/_vendor/rtichoke_viz/" required = { f"{prefix}VENDORED_FROM", - f"{prefix}rtichoke-viz-0.14.0.tar.gz", + f"{prefix}rtichoke-viz-0.18.0.tar.gz", f"{prefix}rtichoke-viz.js", f"{prefix}rtichoke-viz.css", f"{prefix}rtichoke-viz.schema.json", @@ -60,7 +60,7 @@ jobs: f"{prefix}rtichoke-viz-report.schema.json", } assert required <= names - assert f"{prefix}rtichoke-viz-0.10.0.tar.gz" not in names + assert f"{prefix}rtichoke-viz-0.14.0.tar.gz" not in names PY - name: Run tests diff --git a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM index 81b9d552..1a70db5d 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM +++ b/src/rtichoke/_vendor/rtichoke_viz/VENDORED_FROM @@ -1,5 +1,5 @@ repository=https://github.com/uriahf/rtichoke_viz -release=v0.14.0 -source_commit=ec3a382656ce5b1c735175d5a8fd1ceb4f153eaf -archive=rtichoke-viz-0.14.0.tar.gz -sha256=b7c30d12db4b3f8008ef035bd4f7e04c09d599ced5bc8606a4a4141c5cdf48ee +release=v0.18.0 +source_commit=dbabedb495ab70062ee635cd9d59eefcafe55a43 +archive=rtichoke-viz-0.18.0.tar.gz +sha256=1cd3af962be8357d0fd4c2f2ecf5e5953774683de8854ead2facbac807b6bc84 diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.14.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.14.0.tar.gz deleted file mode 100644 index 0754908c..00000000 Binary files a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.14.0.tar.gz and /dev/null differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.18.0.tar.gz b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.18.0.tar.gz new file mode 100644 index 00000000..000c1cf1 Binary files /dev/null and b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-0.18.0.tar.gz differ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-report.schema.json b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-report.schema.json index c763e815..2fc2592c 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-report.schema.json +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-report.schema.json @@ -542,6 +542,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -572,6 +597,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -1728,6 +1758,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -1758,6 +1813,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -2282,6 +2342,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -2836,6 +2921,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -3388,6 +3498,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -4208,6 +4335,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -5549,6 +5693,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -5579,6 +5748,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -6735,6 +6909,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -6765,6 +6964,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -7289,6 +7493,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -7843,6 +8072,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -8395,6 +8649,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -9215,6 +9486,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -10534,6 +10822,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -10564,6 +10877,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -11720,6 +12038,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -11750,6 +12093,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -12274,6 +12622,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -12828,6 +13201,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -13380,6 +13778,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -14200,6 +14615,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json index ba024b43..46395a29 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz-v2.schema.json @@ -501,6 +501,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -531,6 +556,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -1687,6 +1717,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -1717,6 +1772,11 @@ "cutoff": { "type": "number" }, + "ppcr": { + "minimum": 0, + "maximum": 1, + "type": "number" + }, "sensitivity": { "minimum": 0, "maximum": 1, @@ -2241,6 +2301,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -2795,6 +2880,31 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "anyOf": [ + { + "const": "probability_threshold", + "type": "string" + }, + { + "const": "ppcr", + "type": "string" + } + ] + } + } + } + } + }, { "type": "object", "required": [ @@ -3347,6 +3457,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ @@ -4167,6 +4294,23 @@ } } }, + { + "type": "object", + "properties": { + "operatingPoint": { + "type": "object", + "required": [ + "dimension" + ], + "properties": { + "dimension": { + "const": "probability_threshold", + "type": "string" + } + } + } + } + }, { "type": "object", "required": [ diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css index d57025dc..e1c102e4 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.css @@ -7,6 +7,131 @@ display: block; } +/* Plotly-like interactive legend styling */ +.rtichoke-legend { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 1rem; + margin-bottom: 0.5rem; + font-family: Arial, Helvetica, sans-serif; + font-size: 12px; +} + +.rtichoke-legend-item { + display: inline-flex; + align-items: center; + gap: 0.4rem; + background: transparent; + border: none; + padding: 2px 4px; + margin: 0; + font-family: inherit; + font-size: 12px; + line-height: 1; + color: #2a2a2a; + cursor: pointer; + border-radius: 2px; + user-select: none; + transition: opacity 0.15s ease, color 0.15s ease; +} + +.rtichoke-legend-item:focus-visible { + outline: 2px solid #2563eb; + outline-offset: 1px; +} + +.rtichoke-legend-swatch { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 12px; + flex-shrink: 0; +} + +.rtichoke-legend-line { + display: block; + width: 16px; + height: 2px; + border-radius: 1px; + transition: background-color 0.15s ease; +} + +.rtichoke-legend-label { + font-size: 12px; +} + +/* Hidden / disabled state matching Plotly legend muted appearance */ +.rtichoke-legend-item[aria-pressed="false"] { + color: #a0a0a0; +} + +.rtichoke-legend-item[aria-pressed="false"] .rtichoke-legend-line { + background-color: #a0a0a0 !important; +} + +.rtichoke-operating-point-chart { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rtichoke-operating-point-content { + overflow-x: auto; +} + +.rtichoke-operating-point-control { + display: flex; + flex-direction: column; + gap: 0.25rem; + box-sizing: border-box; +} + +.rtichoke-operating-point-label { + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + color: #374151; +} + +.rtichoke-operating-point-value { + font-weight: 600; + color: #111827; +} + +.rtichoke-operating-point-slider { + width: 100%; + margin: 0; + box-sizing: border-box; + cursor: pointer; +} + +.rtichoke-horizon-chart { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.rtichoke-horizon-control { + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + color: #374151; + margin-bottom: 0.25rem; +} + +.rtichoke-horizon-select { + font-family: inherit; + font-size: 0.875rem; + padding: 0.25rem 0.5rem; + border: 1px solid #d1d5db; + border-radius: 4px; + background-color: #ffffff; + color: #111827; + margin-left: 0.5rem; +} + .rtichoke-report { display: flex; flex-direction: column; diff --git a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js index 7abfda3b..d1bcd572 100644 --- a/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js +++ b/src/rtichoke/_vendor/rtichoke_viz/rtichoke-viz.js @@ -2915,6 +2915,24 @@ var BaseChartV2SpecSchema = Type.Object({ yAxis: AxisSpecSchema, references: Type.Optional(Type.Array(ReferenceLineV2SpecSchema)) }); +var OperatingPointDimensionSchema = Type.Union([ + Type.Literal("probability_threshold"), + Type.Literal("ppcr") +]); +var OperatingPointSpecSchema = Type.Object({ + operatingPoint: Type.Optional( + Type.Object({ + dimension: OperatingPointDimensionSchema + }) + ) +}); +var ThresholdOperatingPointSpecSchema = Type.Object({ + operatingPoint: Type.Optional( + Type.Object({ + dimension: Type.Literal("probability_threshold") + }) + ) +}); // src/spec/v2/calibration.ts var DiscreteCalibrationV2DatumSchema = Type.Object({ @@ -3006,6 +3024,7 @@ var DecisionCurveV2ReferenceSchema = Type.Union([ ]); var DecisionCurveV2SpecSchema = Type.Intersect([ BaseChartV2SpecSchema, + ThresholdOperatingPointSpecSchema, Type.Object({ type: Type.Literal("decision_curve"), evaluations: Type.Array(DecisionCurveV2EvaluationSchema, { minItems: 1 }), @@ -3026,6 +3045,7 @@ var GainsV2DatumSchema = Type.Object({ }); var GainsV2SpecSchema = Type.Intersect([ BaseChartV2SpecSchema, + OperatingPointSpecSchema, Type.Object({ type: Type.Literal("gains"), data: Type.Array(GainsV2DatumSchema), @@ -3086,6 +3106,7 @@ var InterventionsAvoidedV2ReferenceSchema = Type.Union([ ]); var InterventionsAvoidedV2SpecSchema = Type.Intersect([ BaseChartV2SpecSchema, + ThresholdOperatingPointSpecSchema, Type.Object({ type: Type.Literal("interventions_avoided"), evaluations: Type.Array(InterventionsAvoidedV2EvaluationSchema, { minItems: 1 }), @@ -3106,6 +3127,7 @@ var LiftV2DatumSchema = Type.Object({ }); var LiftV2SpecSchema = Type.Intersect([ BaseChartV2SpecSchema, + OperatingPointSpecSchema, Type.Object({ type: Type.Literal("lift"), data: Type.Array(LiftV2DatumSchema), @@ -3118,11 +3140,13 @@ var LiftV2SpecSchema = Type.Intersect([ var PrecisionRecallV2DatumSchema = Type.Object({ seriesId: Type.String(), cutoff: Type.Number(), + ppcr: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), sensitivity: Type.Number({ minimum: 0, maximum: 1 }), ppv: Type.Number({ minimum: 0, maximum: 1 }) }); var PrecisionRecallV2SpecSchema = Type.Intersect([ BaseChartV2SpecSchema, + OperatingPointSpecSchema, Type.Object({ type: Type.Literal("precision_recall"), data: Type.Array(PrecisionRecallV2DatumSchema), @@ -3135,11 +3159,13 @@ var PrecisionRecallV2SpecSchema = Type.Intersect([ var RocV2DatumSchema = Type.Object({ seriesId: Type.String(), cutoff: Type.Number(), + ppcr: Type.Optional(Type.Number({ minimum: 0, maximum: 1 })), sensitivity: Type.Number({ minimum: 0, maximum: 1 }), specificity: Type.Number({ minimum: 0, maximum: 1 }) }); var RocV2SpecSchema = Type.Intersect([ BaseChartV2SpecSchema, + OperatingPointSpecSchema, Type.Object({ type: Type.Literal("roc"), data: Type.Array(RocV2DatumSchema), @@ -19253,7 +19279,7 @@ var RTICHOKE_COLORS2 = [ ]; var RTICHOKE_BROWSER_THEME = { width: 600, - height: 600, + height: 500, margins: { top: 28, right: 28, bottom: 58, left: 66 }, background: "#ffffff", frame: { color: "#444444", width: 1 }, @@ -19273,9 +19299,9 @@ var RTICHOKE_BROWSER_THEME = { }, colors: RTICHOKE_COLORS2, line: { width: 2, dash: null }, - marker: { radius: 5, fill: null, stroke: "#ffffff", strokeWidth: 1.5 }, - reference: { color: "#BEBEBE", width: 2, dash: "4,4" }, - legend: { position: "top", swatchWidth: 15, columns: null }, + marker: { radius: 2.5, fill: null, stroke: "#ffffff", strokeWidth: 0.5 }, + reference: { color: "#BEBEBE", width: 1.5, dash: "2,3" }, + legend: { position: "top", swatchWidth: 12, columns: null }, tip: { digits: 3 } }; function mergeTheme(options) { @@ -19298,10 +19324,10 @@ function mergeTheme(options) { }; } function resolveV2RenderOptions(groupsOrCount, options = {}) { - const groups2 = typeof groupsOrCount === "number" ? Array.from( + const allGroups = options.allGroups ?? (typeof groupsOrCount === "number" ? Array.from( { length: groupsOrCount }, (_, index2) => `group-${index2 + 1}` - ) : [...groupsOrCount]; + ) : [...groupsOrCount]); const theme = mergeTheme(options); if (!Number.isFinite(theme.width) || theme.width <= 0 || !Number.isFinite(theme.height) || theme.height <= 0) throw new Error( @@ -19309,21 +19335,205 @@ function resolveV2RenderOptions(groupsOrCount, options = {}) { ); if (!Number.isInteger(theme.tip.digits) || theme.tip.digits < 0 || theme.tip.digits > 20) throw new Error("Renderer tip digits must be an integer between 0 and 20"); - const colors = groups2.length <= 1 ? ["#000000"] : [...theme.colors]; - if (colors.length < groups2.length) + const colors = allGroups.length <= 1 ? ["#000000"] : [...theme.colors]; + if (colors.length < allGroups.length) throw new Error( "Renderer colors must contain at least one color per display group" ); - const assigned = colors.slice(0, Math.max(groups2.length, 1)); + const assigned = colors.slice(0, Math.max(allGroups.length, 1)); + const showLegend = options.showLegend ?? allGroups.length > 1; return { theme: { ...theme, colors: assigned }, - groups: groups2, + groups: allGroups, colors: assigned, colorByGroup: new Map( - groups2.map((group2, index2) => [group2, assigned[index2]]) + allGroups.map((group2, index2) => [group2, assigned[index2]]) ), - showLegend: groups2.length > 1 + showLegend + }; +} +function extractOperatingPointValues(spec) { + if (!spec.operatingPoint) return []; + const dim = spec.operatingPoint.dimension; + const key = dim === "probability_threshold" ? spec.type === "decision_curve" || spec.type === "interventions_avoided" ? "threshold" : "cutoff" : "ppcr"; + if (dim === "probability_threshold") { + const seriesIds = spec.series.map((s2) => s2.id); + if (seriesIds.length === 0) return []; + let intersectionSet = null; + for (const id2 of seriesIds) { + const sData = spec.data.filter((datum2) => datum2.seriesId === id2); + const sValues = sData.map((datum2) => datum2[key]).filter( + (val) => typeof val === "number" && Number.isFinite(val) + ); + const sSet = new Set(sValues); + if (intersectionSet === null) { + intersectionSet = sSet; + } else { + const currentIntersection = intersectionSet; + intersectionSet = new Set( + Array.from(currentIntersection).filter((val) => sSet.has(val)) + ); + } + } + return Array.from(intersectionSet ?? []).sort((a2, b) => a2 - b); + } else { + const rawValues = spec.data.map((datum2) => datum2[key]).filter( + (val) => typeof val === "number" && Number.isFinite(val) + ); + const uniqueSorted = [...new Set(rawValues)].sort((a2, b) => a2 - b); + return uniqueSorted; + } +} +function filterSpecByGroups(spec, activeGroups) { + const visibleSeries = spec.series.filter((s2) => activeGroups.has(s2.display.group)); + const visibleSeriesIds = new Set(visibleSeries.map((s2) => s2.id)); + return { + ...spec, + series: visibleSeries, + data: spec.data.filter((d) => visibleSeriesIds.has(d.seriesId)) + }; +} +function renderWithLegendFiltering(spec, options, render, preferredValue, onValueChange) { + const allGroups = displayGroups(spec); + let currentOpVal = preferredValue; + if (allGroups.length <= 1) { + return render(spec, options, currentOpVal, (val) => { + currentOpVal = val; + if (onValueChange) onValueChange(val); + }); + } + const childOptions = { + ...options, + allGroups, + showLegend: false }; + const resolved = resolveV2RenderOptions(allGroups, childOptions); + const { theme, colorByGroup } = resolved; + const labelByGroup = new Map(spec.series.map((s2) => [s2.display.group, s2.display.label])); + const activeGroups = new Set(allGroups); + const container = document.createElement("div"); + container.className = "rtichoke-legend-chart"; + container.style.maxWidth = `${theme.width}px`; + const legendNav = document.createElement("div"); + legendNav.className = "rtichoke-legend"; + legendNav.style.paddingLeft = `${theme.margins.left}px`; + legendNav.setAttribute("aria-label", "Chart legend"); + const buttonsByGroup = /* @__PURE__ */ new Map(); + allGroups.forEach((group2) => { + const btn = document.createElement("button"); + btn.type = "button"; + btn.className = "rtichoke-legend-item"; + btn.setAttribute("aria-pressed", "true"); + const groupLabel = labelByGroup.get(group2) ?? group2; + btn.setAttribute("aria-label", `Toggle series ${groupLabel}`); + const swatch = document.createElement("span"); + swatch.className = "rtichoke-legend-swatch"; + const lineSpan = document.createElement("span"); + lineSpan.className = "rtichoke-legend-line"; + lineSpan.style.backgroundColor = colorByGroup.get(group2) ?? "#000000"; + swatch.append(lineSpan); + const labelSpan = document.createElement("span"); + labelSpan.className = "rtichoke-legend-label"; + labelSpan.textContent = groupLabel; + btn.append(swatch, labelSpan); + legendNav.append(btn); + buttonsByGroup.set(group2, btn); + }); + const contentArea = document.createElement("div"); + contentArea.className = "rtichoke-legend-content"; + const updateChart = () => { + const filteredSpec = filterSpecByGroups(spec, activeGroups); + const chartContent = render(filteredSpec, childOptions, currentOpVal, (val) => { + currentOpVal = val; + if (onValueChange) onValueChange(val); + }); + contentArea.replaceChildren(chartContent); + }; + allGroups.forEach((group2) => { + const btn = buttonsByGroup.get(group2); + btn.addEventListener("click", () => { + const isCurrentlyActive = activeGroups.has(group2); + if (isCurrentlyActive) { + if (activeGroups.size <= 1) { + return; + } + activeGroups.delete(group2); + btn.setAttribute("aria-pressed", "false"); + } else { + activeGroups.add(group2); + btn.setAttribute("aria-pressed", "true"); + } + updateChart(); + }); + }); + container.append(legendNav, contentArea); + updateChart(); + return container; +} +function renderWithOperatingPointSelection(spec, options, render, preferredValue, onValueChange) { + if (!spec.operatingPoint) return render(spec, void 0); + const values2 = extractOperatingPointValues(spec); + if (values2.length === 0) return render(spec, void 0); + const resolved = resolveV2RenderOptions(displayGroups(spec), options); + const { theme } = resolved; + const container = document.createElement("div"); + container.className = "rtichoke-operating-point-chart"; + container.style.maxWidth = `${theme.width}px`; + const control = document.createElement("div"); + control.className = "rtichoke-operating-point-control"; + control.style.marginLeft = `${theme.margins.left}px`; + control.style.marginRight = `${theme.margins.right}px`; + const ariaLabelText = spec.operatingPoint.dimension === "probability_threshold" ? "Probability threshold" : "Predicted positives condition rate (PPCR)"; + const visibleLabelText = spec.operatingPoint.dimension === "probability_threshold" ? "Probability threshold" : "PPCR"; + const label = document.createElement("label"); + label.className = "rtichoke-operating-point-label"; + const labelSpan = document.createElement("span"); + labelSpan.textContent = `${visibleLabelText}: `; + const valueSpan = document.createElement("span"); + valueSpan.className = "rtichoke-operating-point-value"; + const slider = document.createElement("input"); + slider.type = "range"; + slider.className = "rtichoke-operating-point-slider"; + slider.min = "0"; + slider.max = String(values2.length - 1); + slider.step = "1"; + slider.setAttribute("aria-label", ariaLabelText); + let selectedIndex = 0; + if (preferredValue !== void 0) { + const matchIdx = values2.indexOf(preferredValue); + if (matchIdx !== -1) { + selectedIndex = matchIdx; + } + } + const selectedValue = values2[selectedIndex]; + slider.value = String(selectedIndex); + const formattedVal = selectedValue.toFixed(theme.tip.digits); + valueSpan.textContent = formattedVal; + slider.setAttribute("aria-valuetext", formattedVal); + if (onValueChange) { + onValueChange(selectedValue); + } + label.append(labelSpan, valueSpan); + control.append(label, slider); + const chart = document.createElement("div"); + chart.className = "rtichoke-operating-point-content"; + const draw = (val) => { + chart.replaceChildren(render(spec, val)); + }; + slider.addEventListener("input", () => { + const idx = Number(slider.value); + const val = values2[idx]; + const valFormatted = val.toFixed(theme.tip.digits); + valueSpan.textContent = valFormatted; + slider.setAttribute("aria-valuetext", valFormatted); + if (onValueChange) { + onValueChange(val); + } + draw(val); + }); + container.append(chart, control); + draw(selectedValue); + return container; } function displayBySeries(spec) { return new Map(spec.series.map((series) => [series.id, series.display])); @@ -19382,12 +19592,6 @@ function axisOptions2(theme, label, domain) { tickFormat: theme.axis.numberFormat }; } -function frameMark(theme) { - return frame2({ - stroke: theme.frame.color, - strokeWidth: theme.frame.width - }); -} function referenceMarks(spec, theme) { const style = { stroke: theme.reference.color, @@ -19423,9 +19627,73 @@ function referenceMarks(spec, theme) { return marks2; } function finishMarks(marks2, theme) { - marks2.push(frameMark(theme)); return marks2; } +function thinOrdinaryPoints(data, getSeriesId, targetMax = 40) { + const bySeries = /* @__PURE__ */ new Map(); + for (const item of data) { + const id2 = getSeriesId(item); + let list = bySeries.get(id2); + if (!list) { + list = []; + bySeries.set(id2, list); + } + list.push(item); + } + const result = []; + for (const list of bySeries.values()) { + const n = list.length; + if (n <= targetMax) { + result.push(...list); + } else { + const selectedIndices = /* @__PURE__ */ new Set(); + for (let k2 = 0; k2 < targetMax; k2++) { + const idx = Math.round(k2 * (n - 1) / (targetMax - 1)); + selectedIndices.add(idx); + } + for (const idx of selectedIndices) { + result.push(list[idx]); + } + } + } + return result; +} +function ordinaryPointDotMark(data, x2, y2, resolved, customTheme) { + const thinned = thinOrdinaryPoints(data, (d) => d.seriesId, 40); + const theme = resolved.theme; + const r = customTheme?.marker?.radius ?? theme.marker.radius; + const stroke = customTheme?.marker?.stroke ?? theme.marker.stroke; + const strokeWidth = customTheme?.marker?.strokeWidth ?? theme.marker.strokeWidth; + return dot(thinned, { + ariaLabel: "ordinary-point", + x: x2, + y: y2, + fill: "group", + stroke, + strokeWidth, + r, + title: (d) => d.title, + tip: true + }); +} +function operatingPointDotMark(data, x2, y2, resolved, customTheme) { + const isMultiSeries = resolved.groups.length > 1; + const fill = customTheme?.marker?.fill ?? (isMultiSeries ? "group" : "#f6e3be"); + const stroke = customTheme?.marker?.stroke ?? "#1a1a1a"; + const strokeWidth = customTheme?.marker?.strokeWidth ?? 2.5; + const r = customTheme?.marker?.radius ?? 7.5; + return dot(data, { + className: "rtichoke-selected-operating-point", + x: x2, + y: y2, + fill, + stroke, + strokeWidth, + r, + title: (d) => d.title, + tip: true + }); +} function themedPlot(options, theme) { const plot2 = plot(options); for (const label of plot2.querySelectorAll( @@ -19442,27 +19710,40 @@ function themedPlot(options, theme) { if (plot2 instanceof HTMLElement) { plot2.style.fontSize = `${theme.typography.legendSize}px`; for (const swatch of plot2.querySelectorAll( - 'svg[width="15"]' + 'svg[width="15"], div[class*="-swatches"] svg' )) { swatch.setAttribute("width", String(theme.legend.swatchWidth)); + swatch.setAttribute("height", "10"); } } return plot2; } -function renderRocV2(spec, options = {}) { +function renderRocChart(spec, options = {}, selectedOperatingPointValue) { assertV2ReferentialIntegrity(spec); const resolved = resolveV2RenderOptions(displayGroups(spec), options); const { theme } = resolved; - const data = seriesRenderData(spec, spec.data).map((datum2) => ({ - ...datum2, - false_positive_rate: 1 - datum2.specificity, - title: tooltip(theme.tip.digits, [ - ["Series", datum2.label], - ["Cutoff", datum2.cutoff], + const opDim = spec.operatingPoint?.dimension; + const data = seriesRenderData(spec, spec.data).map((datum2) => { + const fpr = 1 - datum2.specificity; + const fields = [["Series", datum2.label]]; + if (opDim === "ppcr") { + if (datum2.ppcr !== void 0) fields.push(["PPCR", datum2.ppcr]); + fields.push(["Cutoff", datum2.cutoff]); + } else { + fields.push(["Cutoff", datum2.cutoff]); + if (datum2.ppcr !== void 0) fields.push(["PPCR", datum2.ppcr]); + } + fields.push( ["Sensitivity", datum2.sensitivity], - ["Specificity", datum2.specificity] - ]) - })); + ["Specificity", datum2.specificity], + ["False Positive Rate", fpr] + ); + return { + ...datum2, + false_positive_rate: fpr, + title: tooltip(theme.tip.digits, fields) + }; + }); const marks2 = referenceMarks(spec, theme); marks2.push( line(data, { @@ -19472,10 +19753,26 @@ function renderRocV2(spec, options = {}) { stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0, - title: "title", + title: (d) => d.title, tip: true - }) + }), + ordinaryPointDotMark( + data, + "false_positive_rate", + "sensitivity", + resolved, + options.theme + ) ); + if (selectedOperatingPointValue !== void 0 && spec.operatingPoint) { + const dimField = spec.operatingPoint.dimension === "probability_threshold" ? "cutoff" : "ppcr"; + const selectedPoints = data.filter((datum2) => datum2[dimField] === selectedOperatingPointValue); + if (selectedPoints.length > 0) { + marks2.push( + operatingPointDotMark(selectedPoints, "false_positive_rate", "sensitivity", resolved, options.theme) + ); + } + } return themedPlot( { ...basePlotOptions(resolved, spec), @@ -19486,6 +19783,24 @@ function renderRocV2(spec, options = {}) { theme ); } +function renderRocV2(spec, options = {}) { + return renderWithLegendFiltering( + spec, + options, + (filteredSpec, opts, preferredOpVal, onOpValChange) => renderWithHorizonSelection( + filteredSpec, + (selected, pOpVal, onOpChange) => renderWithOperatingPointSelection( + selected, + opts, + (specWithOp, activeOpVal) => renderRocChart(specWithOp, opts, activeOpVal), + pOpVal, + onOpChange + ), + preferredOpVal, + onOpValChange + ) + ); +} function renderCalibrationV2(spec, options = {}) { assertV2ReferentialIntegrity(spec); const resolved = resolveV2RenderOptions(displayGroups(spec), options); @@ -19509,7 +19824,7 @@ function renderCalibrationV2(spec, options = {}) { stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0, - title: "title", + title: (d) => d.title, tip: true }) ); @@ -19523,7 +19838,7 @@ function renderCalibrationV2(spec, options = {}) { stroke: theme.marker.stroke, strokeWidth: theme.marker.strokeWidth, r: theme.marker.radius, - title: "title", + title: (d) => d.title, tip: true }) ); @@ -19583,7 +19898,7 @@ function renderCalibrationV2(spec, options = {}) { y: "count", fill: "group", fillOpacity: 1 / Math.max(resolved.groups.length, 1), - title: "title", + title: (d) => d.title, tip: true }) ], @@ -19599,24 +19914,48 @@ function renderCalibrationV2(spec, options = {}) { container.append(calibration, histogram); return container; } -function renderLineChart(spec, options, x2, y2) { +function renderLineChart(spec, options, x2, y2, selectedOperatingPointValue) { assertV2ReferentialIntegrity(spec); const resolved = resolveV2RenderOptions(displayGroups(spec), options); const { theme } = resolved; + const opDim = spec.operatingPoint?.dimension; const data = seriesRenderData( spec, spec.data ).map((datum2) => { const values2 = datum2; - const yLabel = y2 === "ppv" ? "PPV" : y2 === "sensitivity" ? "Sensitivity" : "Lift"; + const fields = [["Series", datum2.label]]; + if (spec.type === "precision_recall") { + if (opDim === "ppcr") { + if (values2.ppcr !== void 0) fields.push(["PPCR", values2.ppcr]); + fields.push(["Cutoff", values2.cutoff]); + } else { + fields.push(["Cutoff", values2.cutoff]); + if (values2.ppcr !== void 0) fields.push(["PPCR", values2.ppcr]); + } + fields.push(["Sensitivity", values2.sensitivity], ["PPV", values2.ppv]); + } else if (spec.type === "gains") { + if (opDim === "probability_threshold") { + fields.push(["Cutoff", values2.cutoff]); + fields.push(["PPCR", values2.ppcr]); + } else { + fields.push(["PPCR", values2.ppcr]); + fields.push(["Cutoff", values2.cutoff]); + } + fields.push(["Sensitivity", values2.sensitivity]); + } else if (spec.type === "lift") { + if (opDim === "probability_threshold") { + fields.push(["Cutoff", values2.cutoff]); + fields.push(["PPCR", values2.ppcr]); + } else { + fields.push(["PPCR", values2.ppcr]); + fields.push(["Cutoff", values2.cutoff]); + } + fields.push(["Lift", values2.lift]); + } return { ...datum2, - title: tooltip(theme.tip.digits, [ - ["Series", datum2.label], - ["Cutoff", values2.cutoff], - [x2 === "ppcr" ? "PPCR" : "Sensitivity", values2[x2]], - [yLabel, values2[y2]] - ]) + title: tooltip(theme.tip.digits, fields) }; }); const marks2 = referenceMarks(spec, theme); @@ -19628,10 +19967,27 @@ function renderLineChart(spec, options, x2, y2) { stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0, - title: "title", + title: (d) => d.title, tip: true - }) + }), + ordinaryPointDotMark( + data, + x2, + y2, + resolved, + options.theme + ) ); + if (selectedOperatingPointValue !== void 0 && spec.operatingPoint) { + const dim = spec.operatingPoint.dimension; + const dimField = dim === "probability_threshold" ? "cutoff" : "ppcr"; + const selectedPoints = data.filter((datum2) => datum2[dimField] === selectedOperatingPointValue); + if (selectedPoints.length > 0) { + marks2.push( + operatingPointDotMark(selectedPoints, x2, y2, resolved, options.theme) + ); + } + } return themedPlot( { ...basePlotOptions(resolved, spec), @@ -19663,14 +20019,17 @@ function selectHorizonSpec(spec, horizon) { ) }; } -function renderWithHorizonSelection(spec, render) { +function renderWithHorizonSelection(spec, render, preferredValue, onValueChange) { const availableHorizons = horizons(spec); - if (availableHorizons.length <= 1) return render(spec); + if (availableHorizons.length <= 1) return render(spec, preferredValue, onValueChange); + let currentOpValue = preferredValue; const container = document.createElement("div"); container.className = "rtichoke-horizon-chart"; const control = document.createElement("label"); + control.className = "rtichoke-horizon-control"; control.textContent = "Fixed Time Horizon: "; const select = document.createElement("select"); + select.className = "rtichoke-horizon-select"; select.setAttribute("aria-label", "Fixed Time Horizon"); for (const horizon of availableHorizons) { const option = document.createElement("option"); @@ -19681,7 +20040,16 @@ function renderWithHorizonSelection(spec, render) { control.append(select); const chart = document.createElement("div"); const draw = (horizon) => { - chart.replaceChildren(render(selectHorizonSpec(spec, horizon))); + chart.replaceChildren( + render( + selectHorizonSpec(spec, horizon), + currentOpValue, + (val) => { + currentOpValue = val; + if (onValueChange) onValueChange(val); + } + ) + ); }; select.addEventListener("change", () => draw(Number(select.value))); container.append(control, chart); @@ -19689,9 +20057,21 @@ function renderWithHorizonSelection(spec, render) { return container; } function renderHorizonLineChart(spec, options, x2, y2) { - return renderWithHorizonSelection( + return renderWithLegendFiltering( spec, - (selected) => renderLineChart(selected, options, x2, y2) + options, + (filteredSpec, opts, preferredOpVal, onOpValChange) => renderWithHorizonSelection( + filteredSpec, + (selected, pOpVal, onOpChange) => renderWithOperatingPointSelection( + selected, + opts, + (specWithOp, activeOpVal) => renderLineChart(specWithOp, opts, x2, y2, activeOpVal), + pOpVal, + onOpChange + ), + preferredOpVal, + onOpValChange + ) ); } function renderPrecisionRecallV2(spec, options = {}) { @@ -19707,11 +20087,26 @@ function renderLiftV2(spec, options = {}) { // src/render/decision-curve.ts function renderDecisionCurveV2(spec, options = {}) { assertV2ReferentialIntegrity(spec); - return renderWithHorizonSelection(spec, (selected) => renderDecisionCurveChart(selected, options)); + return renderWithLegendFiltering( + spec, + options, + (filteredSpec, opts, preferredOpVal, onOpValChange) => renderWithHorizonSelection( + filteredSpec, + (selected, pOpVal, onOpChange) => renderWithOperatingPointSelection( + selected, + opts, + (specWithOp, activeOpVal) => renderDecisionCurveChart(specWithOp, opts, activeOpVal), + pOpVal, + onOpChange + ), + preferredOpVal, + onOpValChange + ) + ); } -function renderDecisionCurveChart(spec, options) { +function renderDecisionCurveChart(spec, options, selectedOperatingPointValue) { const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; - const resolved = resolveV2RenderOptions(groups2, options); + const resolved = resolveV2RenderOptions(groups2, { ...options, showLegend: false }); const { theme } = resolved; const displayBySeries2 = new Map(spec.series.map((series) => [series.id, series.display])); const labelByGroup = new Map(spec.series.map((series) => [series.display.group, series.display.label])); @@ -19719,25 +20114,36 @@ function renderDecisionCurveChart(spec, options) { ...datum2, group: displayBySeries2.get(datum2.seriesId).group, label: displayBySeries2.get(datum2.seriesId).label, - title: `Series: ${displayBySeries2.get(datum2.seriesId).label} -Threshold: ${datum2.threshold.toFixed(theme.tip.digits)} -Net Benefit: ${datum2.netBenefit.toFixed(theme.tip.digits)}` + title: tooltip(theme.tip.digits, [ + ["Series", displayBySeries2.get(datum2.seriesId).label], + ["Threshold", datum2.threshold], + ["Net Benefit", datum2.netBenefit] + ]) })); - const referenceStyle = { stroke: theme.reference.color, strokeWidth: theme.reference.width, strokeDasharray: theme.reference.dash }; + const defaultZeroStyle = { stroke: theme.reference.color, strokeWidth: theme.reference.width, strokeDasharray: theme.reference.dash }; + const defaultPathStyle = { stroke: theme.reference.color, strokeWidth: theme.reference.width, strokeDasharray: "4,3" }; const marks2 = []; for (const reference of spec.references) { if (reference.benchmark === "treat_none") { - marks2.push(ruleY([0], { ...referenceStyle, title: () => reference.label ?? "Treat None" })); + marks2.push(ruleY([0], { ...defaultZeroStyle, title: () => reference.label ?? "Treat None" })); } else { - marks2.push(line(reference.points, { x: "x", y: "y", ...referenceStyle, title: () => reference.label ?? `Treat All \u2014 ${reference.population}` })); + marks2.push(line(reference.points, { x: "x", y: "y", ...defaultPathStyle, title: () => reference.label ?? `Treat All \u2014 ${reference.population}` })); } } marks2.push( - line(data, { x: "threshold", y: "netBenefit", z: "seriesId", stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0, title: "title", tip: true }), - frame2({ stroke: theme.frame.color, strokeWidth: theme.frame.width }) + line(data, { x: "threshold", y: "netBenefit", z: "seriesId", stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0 }), + ordinaryPointDotMark(data, "threshold", "netBenefit", resolved, options.theme) ); + if (selectedOperatingPointValue !== void 0 && spec.operatingPoint) { + const selectedPoints = data.filter((datum2) => datum2.threshold === selectedOperatingPointValue); + if (selectedPoints.length > 0) { + marks2.push( + operatingPointDotMark(selectedPoints, "threshold", "netBenefit", resolved, options.theme) + ); + } + } const axis2 = (label, domain) => ({ label, domain, grid: false, line: true, ticks: theme.axis.ticks, tickSize: theme.axis.tickSize, tickPadding: theme.axis.tickPadding, tickFormat: theme.axis.numberFormat }); - const plot2 = plot({ + return themedPlot({ width: theme.width, height: theme.height, marginTop: theme.margins.top, @@ -19749,22 +20155,32 @@ Net Benefit: ${datum2.netBenefit.toFixed(theme.tip.digits)}` x: axis2(spec.xAxis.label, spec.xAxis.domain), y: axis2(spec.yAxis.label, spec.yAxis.domain), marks: marks2 - }); - for (const label of plot2.querySelectorAll('[aria-label$="axis label"] text')) { - label.style.fontSize = `${theme.typography.axisTitleSize}px`; - label.style.fontWeight = String(theme.typography.axisTitleWeight); - } - return plot2; + }, theme); } // src/render/interventions-avoided.ts function renderInterventionsAvoidedV2(spec, options = {}) { assertV2ReferentialIntegrity(spec); - return renderWithHorizonSelection(spec, (selected) => renderInterventionsAvoidedChart(selected, options)); + return renderWithLegendFiltering( + spec, + options, + (filteredSpec, opts, preferredOpVal, onOpValChange) => renderWithHorizonSelection( + filteredSpec, + (selected, pOpVal, onOpChange) => renderWithOperatingPointSelection( + selected, + opts, + (specWithOp, activeOpVal) => renderInterventionsAvoidedChart(specWithOp, opts, activeOpVal), + pOpVal, + onOpChange + ), + preferredOpVal, + onOpValChange + ) + ); } -function renderInterventionsAvoidedChart(spec, options) { +function renderInterventionsAvoidedChart(spec, options, selectedOperatingPointValue) { const groups2 = [...new Set(spec.series.map((series) => series.display.group))]; - const resolved = resolveV2RenderOptions(groups2, options); + const resolved = resolveV2RenderOptions(groups2, { ...options, showLegend: false }); const { theme } = resolved; const displayBySeries2 = new Map(spec.series.map((series) => [series.id, series.display])); const labelByGroup = new Map(spec.series.map((series) => [series.display.group, series.display.label])); @@ -19772,25 +20188,36 @@ function renderInterventionsAvoidedChart(spec, options) { ...datum2, group: displayBySeries2.get(datum2.seriesId).group, label: displayBySeries2.get(datum2.seriesId).label, - title: `Series: ${displayBySeries2.get(datum2.seriesId).label} -Threshold: ${datum2.threshold.toFixed(theme.tip.digits)} -Interventions Avoided: ${datum2.interventionsAvoided.toFixed(theme.tip.digits)}` + title: tooltip(theme.tip.digits, [ + ["Series", displayBySeries2.get(datum2.seriesId).label], + ["Threshold", datum2.threshold], + ["Interventions Avoided", datum2.interventionsAvoided] + ]) })); - const referenceStyle = { stroke: theme.reference.color, strokeWidth: theme.reference.width, strokeDasharray: theme.reference.dash }; + const defaultZeroStyle = { stroke: theme.reference.color, strokeWidth: theme.reference.width, strokeDasharray: theme.reference.dash }; + const defaultPathStyle = { stroke: theme.reference.color, strokeWidth: theme.reference.width, strokeDasharray: "4,3" }; const marks2 = []; for (const reference of spec.references) { if (reference.benchmark === "treat_all") { - marks2.push(ruleY([0], { ...referenceStyle, title: () => reference.label ?? "Treat All" })); + marks2.push(ruleY([0], { ...defaultZeroStyle, title: () => reference.label ?? "Treat All" })); } else { - marks2.push(line(reference.points, { x: "x", y: "y", ...referenceStyle, title: () => reference.label ?? `Treat None \u2014 ${reference.population}` })); + marks2.push(line(reference.points, { x: "x", y: "y", ...defaultPathStyle, title: () => reference.label ?? `Treat None \u2014 ${reference.population}` })); } } marks2.push( - line(data, { x: "threshold", y: "interventionsAvoided", z: "seriesId", stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0, title: "title", tip: true }), - frame2({ stroke: theme.frame.color, strokeWidth: theme.frame.width }) + line(data, { x: "threshold", y: "interventionsAvoided", z: "seriesId", stroke: "group", strokeWidth: theme.line.width, strokeDasharray: theme.line.dash ?? void 0 }), + ordinaryPointDotMark(data, "threshold", "interventionsAvoided", resolved, options.theme) ); + if (selectedOperatingPointValue !== void 0 && spec.operatingPoint) { + const selectedPoints = data.filter((datum2) => datum2.threshold === selectedOperatingPointValue); + if (selectedPoints.length > 0) { + marks2.push( + operatingPointDotMark(selectedPoints, "threshold", "interventionsAvoided", resolved, options.theme) + ); + } + } const axis2 = (label, domain) => ({ label, domain, grid: false, line: true, ticks: theme.axis.ticks, tickSize: theme.axis.tickSize, tickPadding: theme.axis.tickPadding, tickFormat: theme.axis.numberFormat }); - const plot2 = plot({ + return themedPlot({ width: theme.width, height: theme.height, marginTop: theme.margins.top, @@ -19802,12 +20229,7 @@ Interventions Avoided: ${datum2.interventionsAvoided.toFixed(theme.tip.digits)}` x: axis2(spec.xAxis.label, spec.xAxis.domain), y: axis2(spec.yAxis.label, spec.yAxis.domain), marks: marks2 - }); - for (const label of plot2.querySelectorAll('[aria-label$="axis label"] text')) { - label.style.fontSize = `${theme.typography.axisTitleSize}px`; - label.style.fontWeight = String(theme.typography.axisTitleWeight); - } - return plot2; + }, theme); } // src/render/performance-table.ts @@ -23605,25 +24027,33 @@ function renderReportV1_1(spec, options) { const useSectionGroupTabs = options.sectionGroupPresentation === "tabs" && groups2.length > 1; let groupIndex2 = 0; let renderedSectionGroupTabs = false; - for (const item of sectionSpec.items) { + let itemIndex = 0; + while (itemIndex < sectionSpec.items.length) { + const item = sectionSpec.items[itemIndex]; if (item.type === "component") { - secSection.append(renderComponent(item, directCompHeadingTag)); - } else if (item.type === "group") { - if (useSectionGroupTabs) { - if (renderedSectionGroupTabs) { - continue; - } - renderedSectionGroupTabs = true; + let runEnd = itemIndex; + while (runEnd < sectionSpec.items.length && sectionSpec.items[runEnd].type === "component") { + runEnd++; + } + const compRun = sectionSpec.items.slice( + itemIndex, + runEnd + ); + const useComponentTabs = options.sectionComponentPresentation === "tabs" && compRun.length > 1; + if (useComponentTabs) { const tablist = document.createElement("div"); - tablist.className = "rtichoke-report__tablist rtichoke-report__section-group-tablist"; + tablist.className = "rtichoke-report__tablist"; tablist.setAttribute("role", "tablist"); tablist.setAttribute("aria-labelledby", secHeadingDomId); const tabs = []; const panels = []; - groups2.forEach((group2, tabIndex) => { - const groupNav = secNav.groups[tabIndex]; + compRun.forEach((comp, compIdx) => { const tabDomId = generateUniqueDomId( - `section-group-tab-${sectionSpec.id}-${group2.id}`, + `tab-${sectionSpec.id}-${comp.id}`, + usedDomIds + ); + const panelDomId = generateUniqueDomId( + `panel-${sectionSpec.id}-${comp.id}`, usedDomIds ); const tab = document.createElement("button"); @@ -23631,25 +24061,75 @@ function renderReportV1_1(spec, options) { tab.type = "button"; tab.id = tabDomId; tab.setAttribute("role", "tab"); - tab.setAttribute("aria-controls", groupNav.domId); - tab.setAttribute("aria-selected", tabIndex === 0 ? "true" : "false"); - tab.tabIndex = tabIndex === 0 ? 0 : -1; - tab.textContent = group2.title; - const panel = renderGroup(group2, groupNav, tabDomId); - panel.classList.add("rtichoke-report__tabpanel"); + tab.setAttribute("aria-controls", panelDomId); + tab.setAttribute("aria-selected", compIdx === 0 ? "true" : "false"); + tab.tabIndex = compIdx === 0 ? 0 : -1; + tab.textContent = comp.title || comp.id; + const panel = document.createElement("section"); + panel.className = "rtichoke-report__component rtichoke-report__tabpanel"; + panel.id = panelDomId; panel.setAttribute("role", "tabpanel"); panel.setAttribute("aria-labelledby", tabDomId); panel.tabIndex = 0; - panel.hidden = tabIndex !== 0; + panel.dataset.componentId = comp.id; + panel.hidden = compIdx !== 0; + const content = document.createElement("div"); + content.className = "rtichoke-report__component-content"; + content.append(renderStandaloneComponentContent(comp.spec)); + panel.append(content); tabs.push(tab); panels.push(panel); tablist.append(tab); }); wireTabInteraction(tabs, panels); secSection.append(tablist, ...panels); + itemIndex = runEnd; + } else { + secSection.append(renderComponent(item, directCompHeadingTag)); + itemIndex++; + } + } else if (item.type === "group") { + if (useSectionGroupTabs) { + if (!renderedSectionGroupTabs) { + renderedSectionGroupTabs = true; + const tablist = document.createElement("div"); + tablist.className = "rtichoke-report__tablist rtichoke-report__section-group-tablist"; + tablist.setAttribute("role", "tablist"); + tablist.setAttribute("aria-labelledby", secHeadingDomId); + const tabs = []; + const panels = []; + groups2.forEach((group2, tabIndex) => { + const groupNav = secNav.groups[tabIndex]; + const tabDomId = generateUniqueDomId( + `section-group-tab-${sectionSpec.id}-${group2.id}`, + usedDomIds + ); + const tab = document.createElement("button"); + tab.className = "rtichoke-report__tab"; + tab.type = "button"; + tab.id = tabDomId; + tab.setAttribute("role", "tab"); + tab.setAttribute("aria-controls", groupNav.domId); + tab.setAttribute("aria-selected", tabIndex === 0 ? "true" : "false"); + tab.tabIndex = tabIndex === 0 ? 0 : -1; + tab.textContent = group2.title; + const panel = renderGroup(group2, groupNav, tabDomId); + panel.classList.add("rtichoke-report__tabpanel"); + panel.setAttribute("role", "tabpanel"); + panel.setAttribute("aria-labelledby", tabDomId); + panel.tabIndex = 0; + panel.hidden = tabIndex !== 0; + tabs.push(tab); + panels.push(panel); + tablist.append(tab); + }); + wireTabInteraction(tabs, panels); + secSection.append(tablist, ...panels); + } } else { secSection.append(renderGroup(item, secNav.groups[groupIndex2++])); } + itemIndex++; } } root2.append(secSection); @@ -23662,6 +24142,7 @@ function renderReport(spec, options) { } const groupPresentation = options?.groupPresentation ?? "stacked"; const sectionGroupPresentation = options?.sectionGroupPresentation ?? "stacked"; + const sectionComponentPresentation = options?.sectionComponentPresentation ?? "stacked"; if (options !== void 0 && (typeof options !== "object" || options === null || options.groupPresentation !== void 0 && options.groupPresentation !== "stacked" && options.groupPresentation !== "tabs")) { throw new Error( "Invalid render options: groupPresentation must be 'stacked' or 'tabs'" @@ -23672,13 +24153,19 @@ function renderReport(spec, options) { "Invalid render options: sectionGroupPresentation must be 'stacked' or 'tabs'" ); } + if (options !== void 0 && options.sectionComponentPresentation !== void 0 && options.sectionComponentPresentation !== "stacked" && options.sectionComponentPresentation !== "tabs") { + throw new Error( + "Invalid render options: sectionComponentPresentation must be 'stacked' or 'tabs'" + ); + } if (spec.schemaVersion === "1.0") { return renderReportV1_0(spec); } if (spec.schemaVersion === "1.1") { return renderReportV1_1(spec, { groupPresentation, - sectionGroupPresentation + sectionGroupPresentation, + sectionComponentPresentation }); } throw new Error("Invalid ReportSpec"); @@ -23772,7 +24259,9 @@ export { InterventionsAvoidedV2SeriesSchema, InterventionsAvoidedV2SpecSchema, LiftV2SpecSchema, + OperatingPointDimensionSchema, OperatingPointSchema, + OperatingPointSpecSchema, PerformanceEvaluationContextSchema, PerformanceMetricDefinitionSchema, PerformanceMetricIdSchema, @@ -23809,6 +24298,7 @@ export { assertV2ReferentialIntegrity, calibrationSpecFromRtichokeRows, calibrationV2SpecFromRtichokeRows, + extractOperatingPointValues, renderCalibration, renderCalibrationV2, renderDecisionCurveV2, @@ -23821,6 +24311,7 @@ export { renderRoc, renderRocV2, renderSummaryMetrics, + renderWithOperatingPointSelection, resolveV2RenderOptions, rocSpecFromRtichokePython, rocSpecFromRtichokeR, diff --git a/tests/test_rtichoke_viz_vendor.py b/tests/test_rtichoke_viz_vendor.py index 43cf75fa..a1a14042 100644 --- a/tests/test_rtichoke_viz_vendor.py +++ b/tests/test_rtichoke_viz_vendor.py @@ -3,19 +3,19 @@ from pathlib import Path _VENDOR = Path(__file__).parents[1] / "src" / "rtichoke" / "_vendor" / "rtichoke_viz" -_RELEASE_DIR = "rtichoke-viz-0.14.0" -_SHA256 = "b7c30d12db4b3f8008ef035bd4f7e04c09d599ced5bc8606a4a4141c5cdf48ee" -_SOURCE_COMMIT = "ec3a382656ce5b1c735175d5a8fd1ceb4f153eaf" +_RELEASE_DIR = "rtichoke-viz-0.18.0" +_SHA256 = "1cd3af962be8357d0fd4c2f2ecf5e5953774683de8854ead2facbac807b6bc84" +_SOURCE_COMMIT = "dbabedb495ab70062ee635cd9d59eefcafe55a43" -def test_vendored_rtichoke_viz_v0140_provenance_archive_and_schemas(): +def test_vendored_rtichoke_viz_v0180_provenance_archive_and_schemas(): provenance = (_VENDOR / "VENDORED_FROM").read_text() - assert "release=v0.14.0" in provenance + assert "release=v0.18.0" in provenance assert f"source_commit={_SOURCE_COMMIT}" in provenance - assert "archive=rtichoke-viz-0.14.0.tar.gz" in provenance + assert "archive=rtichoke-viz-0.18.0.tar.gz" in provenance assert f"sha256={_SHA256}" in provenance - archive = _VENDOR / "rtichoke-viz-0.14.0.tar.gz" + archive = _VENDOR / "rtichoke-viz-0.18.0.tar.gz" assert hashlib.sha256(archive.read_bytes()).hexdigest() == _SHA256 with tarfile.open(archive, "r:gz") as release: assert set(release.getnames()) == { @@ -30,7 +30,7 @@ def test_vendored_rtichoke_viz_v0140_provenance_archive_and_schemas(): manifest = release.extractfile(f"{_RELEASE_DIR}/MANIFEST") assert manifest is not None assert manifest.read().decode() == ( - f"version=0.14.0\ncommit={_SOURCE_COMMIT}\n" + f"version=0.18.0\ncommit={_SOURCE_COMMIT}\n" ) for filename in ( "rtichoke-viz.css", @@ -43,7 +43,7 @@ def test_vendored_rtichoke_viz_v0140_provenance_archive_and_schemas(): assert packaged is not None assert (_VENDOR / filename).read_bytes() == packaged.read() - assert not (_VENDOR / "rtichoke-viz-0.10.0.tar.gz").exists() + assert not (_VENDOR / "rtichoke-viz-0.14.0.tar.gz").exists() assert (_VENDOR / "rtichoke-viz.js").stat().st_size > 0 assert (_VENDOR / "rtichoke-viz.css").stat().st_size > 0 @@ -58,7 +58,7 @@ def test_vendored_rtichoke_viz_v0140_provenance_archive_and_schemas(): assert '"summary_metrics"' in report_schema -def test_v0140_bundle_keeps_existing_exports_and_time_dependent_surfaces(): +def test_v0180_bundle_keeps_existing_exports_and_time_dependent_surfaces(): bundle = (_VENDOR / "rtichoke-viz.js").read_text(encoding="utf-8") for export_name in ( "renderRoc",