diff --git a/assets/uriahtalks-horizon-explorer.js b/assets/uriahtalks-horizon-explorer.js
new file mode 100644
index 00000000..575f47e9
--- /dev/null
+++ b/assets/uriahtalks-horizon-explorer.js
@@ -0,0 +1,152 @@
+// Great Docs-compatible renderer for the uriahtalks horizon explorer.
+class UriahtalksHorizonExplorer extends HTMLElement {
+ connectedCallback() {
+ if (this.dataset.ready === "true") return;
+ this.dataset.ready = "true";
+
+ const competingAsCensored =
+ this.hasAttribute("competing-as-censored");
+ const min = Number(this.getAttribute("min") || 5);
+ const max = Number(this.getAttribute("max") || 50);
+ const step = Number(this.getAttribute("step") || 5);
+ const initial = Number(this.getAttribute("horizon") || 30);
+ const title = this.getAttribute("heading") || "Explore the horizon";
+ const accent = this.getAttribute("accent-color") || "#7a9a01";
+
+ this.style.setProperty("--uriah-horizon-accent", accent);
+ this.innerHTML = `
+
${title}
+
+
+
+ `;
+
+ const observations = [
+ [1, .482, "primary"], [2, .194, "primary"],
+ [3, .998, "primary"], [4, .372, "primary"],
+ [5, .696, "none"], [6, .284, "competing"],
+ [7, .784, "primary"], [8, .920, "competing"],
+ [9, .630, "none"], [10, .086, "primary"],
+ ].map(([id, fraction, outcome]) => ({
+ id, outcome, time: fraction * max,
+ }));
+
+ const states = {
+ primary: ["🤢", "Primary event"],
+ competing: ["💀", "Competing event"],
+ censored: ["🤬", "Censored"],
+ nonEvent: ["🤨", "Non-event through horizon"],
+ };
+ const input = this.querySelector("input");
+ const output = this.querySelector("output");
+ const chart = this.querySelector(".uriah-horizon-chart");
+ const key = this.querySelector(".uriah-horizon-key");
+ const ns = "http://www.w3.org/2000/svg";
+
+ const classify = (observation, horizon) => {
+ if (observation.time > horizon) return states.nonEvent;
+ if (observation.outcome === "primary") return states.primary;
+ if (observation.outcome === "competing") {
+ return competingAsCensored ? states.censored : states.competing;
+ }
+ return states.censored;
+ };
+
+ const render = () => {
+ const horizon = Number(input.value);
+ output.value = horizon;
+ chart.replaceChildren();
+ const width = 900, height = 390;
+ const margin = { top: 22, right: 28, bottom: 48, left: 58 };
+ const innerWidth = width - margin.left - margin.right;
+ const rowHeight =
+ (height - margin.top - margin.bottom) / observations.length;
+ const x = value => margin.left + (value / max) * innerWidth;
+ const svg = document.createElementNS(ns, "svg");
+ svg.setAttribute("viewBox", `0 0 ${width} ${height}`);
+ svg.setAttribute("class", "uriah-horizon-svg");
+
+ const tickStep = Math.max(step, max / 5);
+ for (let tick = 0; tick <= max; tick += tickStep) {
+ const line = document.createElementNS(ns, "line");
+ line.setAttribute("x1", x(tick));
+ line.setAttribute("x2", x(tick));
+ line.setAttribute("y1", height - margin.bottom);
+ line.setAttribute("y2", height - margin.bottom + 6);
+ line.setAttribute("class", "uriah-axis");
+ svg.appendChild(line);
+ const text = document.createElementNS(ns, "text");
+ text.setAttribute("x", x(tick));
+ text.setAttribute("y", height - margin.bottom + 24);
+ text.setAttribute("class", "uriah-tick");
+ text.textContent = Number(tick.toFixed(2));
+ svg.appendChild(text);
+ }
+
+ const axisLabel = document.createElementNS(ns, "text");
+ axisLabel.setAttribute("x", margin.left + innerWidth / 2);
+ axisLabel.setAttribute("y", height - 5);
+ axisLabel.setAttribute("class", "uriah-axis-label");
+ axisLabel.textContent = "Follow-up time";
+ svg.appendChild(axisLabel);
+
+ const horizonLine = document.createElementNS(ns, "line");
+ horizonLine.setAttribute("x1", x(horizon));
+ horizonLine.setAttribute("x2", x(horizon));
+ horizonLine.setAttribute("y1", margin.top - 8);
+ horizonLine.setAttribute("y2", height - margin.bottom);
+ horizonLine.setAttribute("class", "uriah-horizon-line");
+ svg.appendChild(horizonLine);
+
+ observations.forEach((observation, index) => {
+ const y = margin.top + rowHeight * (index + .5);
+ const displayTime = Math.min(observation.time, horizon);
+ const state = classify(observation, horizon);
+ const label = document.createElementNS(ns, "text");
+ label.setAttribute("x", margin.left - 14);
+ label.setAttribute("y", y);
+ label.setAttribute("class", "uriah-row-label");
+ label.textContent = observation.id;
+ svg.appendChild(label);
+ const followup = document.createElementNS(ns, "line");
+ followup.setAttribute("x1", x(0));
+ followup.setAttribute("x2", x(displayTime));
+ followup.setAttribute("y1", y);
+ followup.setAttribute("y2", y);
+ followup.setAttribute("class", "uriah-followup-line");
+ svg.appendChild(followup);
+ const marker = document.createElementNS(ns, "text");
+ marker.setAttribute("x", x(displayTime));
+ marker.setAttribute("y", y);
+ marker.setAttribute("class", "uriah-emoji-marker");
+ marker.textContent = state[0];
+ const tooltip = document.createElementNS(ns, "title");
+ tooltip.textContent =
+ `Observation ${observation.id}: ${state[1]}; observed time ${observation.time.toFixed(2)}`;
+ marker.appendChild(tooltip);
+ svg.appendChild(marker);
+ });
+
+ chart.appendChild(svg);
+ const active = competingAsCensored
+ ? [states.primary, states.censored, states.nonEvent]
+ : [states.primary, states.competing, states.censored, states.nonEvent];
+ key.replaceChildren(...active.map(state => {
+ const item = document.createElement("span");
+ item.innerHTML = `${state[0]} ${state[1]}`;
+ return item;
+ }));
+ };
+
+ input.addEventListener("input", render);
+ render();
+ }
+}
+
+customElements.define("uriahtalks-horizon-explorer", UriahtalksHorizonExplorer);
+
diff --git a/great-docs.yml b/great-docs.yml
index 2c40814f..a8293201 100644
--- a/great-docs.yml
+++ b/great-docs.yml
@@ -14,7 +14,18 @@ include_in_header:
+ - text: |
+
site:
css: site.css
@@ -85,3 +107,4 @@ reference:
- create_decision_curve
- create_decision_curve_times
- plot_decision_curve
+
diff --git a/site.css b/site.css
index 14644969..29d4039f 100644
--- a/site.css
+++ b/site.css
@@ -182,3 +182,94 @@ footer.footer {
--rtichoke-shadow: none;
}
}
+uriahtalks-horizon-explorer {
+ color: inherit;
+ background: transparent;
+ container-type: inline-size;
+ display: block;
+ margin: 1rem 0 1.5rem;
+}
+
+uriahtalks-horizon-explorer .uriah-horizon-heading {
+ font-size: 1.1em;
+ font-weight: 650;
+ margin-bottom: .65rem;
+}
+
+uriahtalks-horizon-explorer .uriah-horizon-control {
+ display: grid;
+ gap: .3rem;
+ max-width: 34rem;
+}
+
+uriahtalks-horizon-explorer input[type="range"] {
+ accent-color: var(--uriah-horizon-accent);
+ width: 100%;
+}
+
+uriahtalks-horizon-explorer .uriah-horizon-chart {
+ background: transparent;
+ overflow-x: auto;
+ width: 100%;
+}
+
+uriahtalks-horizon-explorer .uriah-horizon-svg {
+ background: transparent;
+ display: block;
+ min-width: 620px;
+ width: 100%;
+}
+
+uriahtalks-horizon-explorer .uriah-axis {
+ stroke: currentColor;
+ stroke-width: 1;
+}
+
+uriahtalks-horizon-explorer .uriah-tick,
+uriahtalks-horizon-explorer .uriah-axis-label,
+uriahtalks-horizon-explorer .uriah-row-label {
+ fill: currentColor;
+ font: 14px Commissioner, system-ui, sans-serif;
+}
+
+uriahtalks-horizon-explorer .uriah-tick,
+uriahtalks-horizon-explorer .uriah-axis-label {
+ text-anchor: middle;
+}
+
+uriahtalks-horizon-explorer .uriah-row-label {
+ dominant-baseline: middle;
+ text-anchor: end;
+}
+
+uriahtalks-horizon-explorer .uriah-followup-line {
+ stroke: color-mix(in srgb, currentColor 38%, transparent);
+ stroke-width: 2;
+}
+
+uriahtalks-horizon-explorer .uriah-horizon-line {
+ stroke: var(--uriah-horizon-accent);
+ stroke-dasharray: 7 6;
+ stroke-width: 4;
+}
+
+uriahtalks-horizon-explorer .uriah-emoji-marker {
+ cursor: help;
+ dominant-baseline: central;
+ font: 30px "Segoe UI Emoji", "Apple Color Emoji", sans-serif;
+ text-anchor: middle;
+}
+
+uriahtalks-horizon-explorer .uriah-horizon-key {
+ display: flex;
+ flex-wrap: wrap;
+ font-size: .9em;
+ gap: .45rem 1.1rem;
+}
+
+@media (max-width: 650px) {
+ uriahtalks-horizon-explorer .uriah-horizon-svg {
+ width: 720px;
+ }
+}
+
diff --git a/user_guide/02-fixed-time-horizons.qmd b/user_guide/02-fixed-time-horizons.qmd
new file mode 100644
index 00000000..1268bd5e
--- /dev/null
+++ b/user_guide/02-fixed-time-horizons.qmd
@@ -0,0 +1,54 @@
+---
+title: "Fixed Time Horizons"
+guide-section: "Before You Validate"
+---
+
+A time-to-event prediction must specify when the outcome is evaluated. Use
+`fixed_time_horizons` to declare those times in the same unit as `times` and
+the prediction model:
+
+```python
+fixed_time_horizons = [5.0, 10.0] # years
+```
+
+Choose clinically meaningful horizons before inspecting performance. Changing
+the horizon changes the outcome being validated.
+
+## Update administrative censoring
+
+At a fixed horizon:
+
+- an event observed after the horizon is a 🤨 non-event *at that horizon*;
+- event-free follow-up ending before the horizon is 🤬 censored;
+- a primary event observed by the horizon remains 🤢; and
+- a competing event observed by the horizon remains 💀.
+
+## Explore the horizon
+
+Move the slider to see how the selected horizon changes each observation. The
+vertical line marks the horizon; information after it is not used.
+
+
+
+
+The symbols are:
+
+- 🤬 censored before the horizon;
+- 🤢 primary event;
+- 🤨 non-event through the horizon; and
+- 💀 competing event.
+
+## Pass horizons to `rtichoke`
+
+```python
+performance_data = rk.prepare_performance_data_times(
+ probs=probs,
+ reals=reals,
+ times=times,
+ fixed_time_horizons=[5.0, 10.0],
+)
+```
+