@liiift-studio/sanity-visitor-insights 0.74.0 → 0.76.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/index.d.mts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-DfjCK0XY.mjs';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-DfjCK0XY.mjs';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-B2W_3Z0t.mjs';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-B2W_3Z0t.mjs';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -297,6 +297,23 @@ interface FunnelStage {
297
297
  partial?: {
298
298
  from: string;
299
299
  };
300
+ /**
301
+ * This rung split by audience segment, in a stable order across rungs.
302
+ *
303
+ * The bar's WIDTH is unchanged — still this rung's share of the entry step — and the split
304
+ * subdivides that width. So a segment collapsing reads as its portion shrinking down the column,
305
+ * against a total that stays honestly anchored to entry.
306
+ *
307
+ * This replaces a second chart. `SurvivalLines` drew the same segments as normalised shares on
308
+ * its own axis, above a funnel drawing the pooled counts: two pictures of one fact, and the
309
+ * share-only one could not print a count, which at rungs of 23 and 12 and 7 is the only fact
310
+ * that survives. Its denominator floor also silenced it at exactly those rungs.
311
+ */
312
+ segments?: ReadonlyArray<{
313
+ key: string;
314
+ label: string;
315
+ value: number;
316
+ }>;
300
317
  }
301
318
  /** Props for FunnelChart. */
302
319
  interface FunnelChartProps {
package/dist/index.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import * as sanity from 'sanity';
2
2
  import React from 'react';
3
- import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-DfjCK0XY.js';
4
- export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-DfjCK0XY.js';
3
+ import { R as ReportEnvelope, a as ReportName, b as RangeKey, M as MetricValue, A as AcquisitionData, c as MeasurementHealthData, D as DiagnosticReport, J as JourneyData, T as TypefaceInterestData } from './ranges-B2W_3Z0t.js';
4
+ export { C as CaptureBasis, d as CaptureEstimate, e as CaptureModel, f as CheckStatus, g as Coverage, h as CrossSourceDay, i as DateRange, j as DiagnosticCheck, E as EmailCampaign, k as EventCutover, l as JourneyOutcome, m as JourneySegment, n as JourneyStep, L as LandingPage, o as LicenceTierRow, P as PREEXISTING, p as REPORT_NAMES, q as ReportError, S as SiteAnalyticsConfig, r as SourceName, s as SourceRow, t as SourceStatus, u as TimelineEvent, v as TypefaceInterestRow, U as UnavailableReason, w as coverageForRange, x as estimated, y as isReportName, z as ok, B as partial, F as previousRange, G as resolveRange, H as unavailable, I as validateSiteConfig, K as valueOrNull } from './ranges-B2W_3Z0t.js';
5
5
 
6
6
  /**
7
7
  * Data hook for the Studio panels.
@@ -297,6 +297,23 @@ interface FunnelStage {
297
297
  partial?: {
298
298
  from: string;
299
299
  };
300
+ /**
301
+ * This rung split by audience segment, in a stable order across rungs.
302
+ *
303
+ * The bar's WIDTH is unchanged — still this rung's share of the entry step — and the split
304
+ * subdivides that width. So a segment collapsing reads as its portion shrinking down the column,
305
+ * against a total that stays honestly anchored to entry.
306
+ *
307
+ * This replaces a second chart. `SurvivalLines` drew the same segments as normalised shares on
308
+ * its own axis, above a funnel drawing the pooled counts: two pictures of one fact, and the
309
+ * share-only one could not print a count, which at rungs of 23 and 12 and 7 is the only fact
310
+ * that survives. Its denominator floor also silenced it at exactly those rungs.
311
+ */
312
+ segments?: ReadonlyArray<{
313
+ key: string;
314
+ label: string;
315
+ value: number;
316
+ }>;
300
317
  }
301
318
  /** Props for FunnelChart. */
302
319
  interface FunnelChartProps {
package/dist/index.js CHANGED
@@ -318,9 +318,6 @@ var MARKS = {
318
318
  "estimate.dot": { key: "ga4Pageviews", alpha: 1, role: "data", note: "One independent estimate of how much GA4 sees" },
319
319
  "estimate.interval": { key: "ga4Pageviews", alpha: 0.35, role: "fill", boundary: "estimate.dot", note: "How wide the sample leaves that estimate \u2014 the dot is its own boundary" },
320
320
  "estimate.rule": { key: "neutral", alpha: 0.45, role: "furniture", note: "The 100% reference a capture rate is read against" },
321
- "shift.now": { key: "vercel", alpha: 1, role: "data", note: "A channel this period" },
322
- "shift.before": { key: "comparison", alpha: 1, role: "data", note: "The same channel last period \u2014 context, not a second answer" },
323
- "shift.link": { key: "comparison", alpha: 0.55, role: "fill", boundary: "shift.before", note: "The distance between the two, which is the finding" },
324
321
  // A segment per colour rather than a dash pattern per segment. Dash was the ONLY channel
325
322
  // separating these lines, and it is the one channel `preserveAspectRatio="none"` distorted —
326
323
  // so two segments could be drawn identically on a wide pane. The keys are reused from the
@@ -662,6 +659,23 @@ function formatDay(iso) {
662
659
  if (Number.isNaN(date.getTime())) return iso;
663
660
  return `${date.getUTCDate()} ${date.toLocaleString("en-GB", { month: "short", timeZone: "UTC" })}`;
664
661
  }
662
+ function segmentColour(index) {
663
+ const names = ["survival.line", "survival.line.2", "survival.line.3", "survival.line.4"];
664
+ return mark(names[index % names.length]);
665
+ }
666
+ function segmentKeys(stages) {
667
+ const seen = /* @__PURE__ */ new Map();
668
+ for (const stage of stages) {
669
+ for (const segment of stage.segments ?? []) {
670
+ if (segment.value > 0 && !seen.has(segment.key)) seen.set(segment.key, segment.label);
671
+ }
672
+ }
673
+ return [...seen].map(([key, label]) => ({ key, label }));
674
+ }
675
+ function segmentShare(value, whole) {
676
+ if (!(whole > 0) || !(value > 0)) return 0;
677
+ return Math.min(1, value / whole);
678
+ }
665
679
  function rateLine(stage, index, entry, share, entryLabel) {
666
680
  if (index === 0) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "100% \u2014 everyone who arrived" });
667
681
  if (stage.partial) {
@@ -703,6 +717,8 @@ function FunnelChart({ stages, measurement }) {
703
717
  const share = entry > 0 ? stage.value / entry : 0;
704
718
  const width = Math.max(1.5, Math.min(1, share) * 100);
705
719
  const active = activeKey === stage.key;
720
+ const hasSplit = (stage.segments?.length ?? 0) > 0;
721
+ const remainder = hasSplit ? Math.max(0, 1 - (stage.segments ?? []).reduce((sum, s) => sum + segmentShare(s.value, stage.value), 0)) : 0;
706
722
  const delta = previous ? previous.value - stage.value : 0;
707
723
  return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", { style: funnelItem, children: [
708
724
  previous && gapLabel(delta, measurement, entry) !== "" && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: funnelGapStyle(measurement === "sequence" && delta > 0 && entry > 0), children: [
@@ -739,13 +755,46 @@ function FunnelChart({ stages, measurement }) {
739
755
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "medium", children: stage.label }),
740
756
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "semibold", children: formatCount(stage.value) })
741
757
  ] }),
742
- stage.partial ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: partialSlot }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: { ...barTrack, height: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...barFill, width: `${width}%` } }) }),
758
+ stage.partial ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: partialSlot }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: { ...barTrack, height: 10 }, children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
759
+ "div",
760
+ {
761
+ style: {
762
+ ...barFill,
763
+ width: `${width}%`,
764
+ display: "flex",
765
+ overflow: "hidden",
766
+ // Transparent when split. Left as the ordinary fill, the parent's own
767
+ // colour showed through wherever the segments did not reach — and on a
768
+ // rung where they sum to 451 of 475 that painted the missing 24 in the
769
+ // FIRST segment's colour, reading as a third segment of it.
770
+ ...hasSplit ? { background: "transparent" } : {}
771
+ },
772
+ children: [
773
+ (stage.segments ?? []).map((segment, segmentIndex) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
774
+ "div",
775
+ {
776
+ style: {
777
+ width: `${segmentShare(segment.value, stage.value) * 100}%`,
778
+ background: segmentColour(segmentIndex),
779
+ height: "100%"
780
+ }
781
+ },
782
+ segment.key
783
+ )),
784
+ hasSplit && remainder > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { width: `${remainder * 100}%`, background: mark("bar.track"), height: "100%" } })
785
+ ]
786
+ }
787
+ ) }),
743
788
  rateLine(stage, index, entry, share, stages[0]?.label ?? "")
744
789
  ]
745
790
  }
746
791
  )
747
792
  ] }, stage.key);
748
793
  }) }),
794
+ segmentKeys(stages).length > 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: funnelSegmentKey, children: segmentKeys(stages).map((segment, index) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
795
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { "aria-hidden": "true", style: { ...hoverSwatch, background: segmentColour(index), display: "inline-block", marginRight: 6 } }),
796
+ segment.label
797
+ ] }, segment.key)) }),
749
798
  stages.some((stage) => stage.partial) && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "Steps with no bar were only tracked for part of this period, so they cover fewer days than the ones above and cannot be drawn against them. Their counts are exact for the days they do cover." }),
750
799
  withheldAny && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: [
751
800
  "Shares are shown only for steps at least ",
@@ -764,6 +813,22 @@ function gapLabel(delta, measurement, entry = 0) {
764
813
  return `${formatCount(-delta)} more \u2014 not a subset of the step above`;
765
814
  }
766
815
  var partialSlot = { height: 10 };
816
+ var funnelSegmentKey = { display: "flex", gap: 14, flexWrap: "wrap", paddingTop: 4 };
817
+ var ladderRow = {
818
+ display: "grid",
819
+ gridTemplateColumns: "minmax(80px, 1.1fr) minmax(60px, 0.8fr) minmax(60px, 2fr) auto",
820
+ gap: 10,
821
+ alignItems: "center"
822
+ };
823
+ var ladderTrack = {
824
+ position: "relative",
825
+ height: 8,
826
+ borderRadius: 4,
827
+ overflow: "hidden",
828
+ background: mark("bar.track")
829
+ };
830
+ var ladderValue = { textAlign: "right", fontVariantNumeric: "tabular-nums", whiteSpace: "nowrap" };
831
+ var ladderMoney = { opacity: 0.7 };
767
832
  var funnelList = { listStyle: "none", margin: 0, padding: 0 };
768
833
  var funnelItem = { display: "grid", gap: 4 };
769
834
  function funnelGapStyle(hasBar) {
@@ -989,169 +1054,33 @@ function EstimateDotPlot({
989
1054
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "The rule marks 100%." })
990
1055
  ] });
991
1056
  }
992
- function SurvivalLines({
993
- segments,
994
- stepLabels,
995
- partialSteps = [],
996
- minDenominator = MIN_RATE_DENOMINATOR
997
- }) {
998
- const [active, setActive] = import_react2.default.useState(null);
999
- const [steppedByKey, setSteppedByKey] = import_react2.default.useState(false);
1000
- if (segments.length < 2 || stepLabels.length < 2) return null;
1001
- const width = 320;
1002
- const height = 132;
1003
- const x = (i) => i / (stepLabels.length - 1) * width;
1004
- const drawn = segments.map((segment, index) => {
1005
- const entry = segment.values[0];
1006
- if (entry === null || entry === void 0 || entry <= 0) return null;
1007
- const points = [];
1008
- const shares = [];
1009
- for (let i = 0; i < segment.values.length && i < stepLabels.length; i++) {
1010
- const value = segment.values[i];
1011
- const previous = i === 0 ? entry : segment.values[i - 1];
1012
- if (value === null || value === void 0) break;
1013
- if (i > 0 && (previous === null || previous === void 0 || previous < minDenominator)) break;
1014
- if (partialSteps.includes(i)) break;
1015
- const share = Math.min(1, value / entry);
1016
- points.push([x(i), height - share * height]);
1017
- shares.push(share);
1018
- }
1019
- if (points.length < 2) return null;
1020
- return { segment, index, points, shares, colour: segmentColour(index) };
1021
- });
1022
- const lines = drawn.filter((d) => d !== null);
1023
- if (lines.length === 0) return null;
1024
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 3, children: [
1025
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: survivalFrame, children: [
1026
- [1, 0.5, 0].map((level) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: survivalYLabel(level), children: level === 1 ? "100%" : level === 0.5 ? "50%" : "0%" }, level)),
1027
- /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(
1028
- "svg",
1029
- {
1030
- viewBox: `0 0 ${width} ${height}`,
1031
- preserveAspectRatio: "none",
1032
- style: { width: "100%", height: 150, overflow: "visible" },
1033
- role: "img",
1034
- "aria-label": survivalSummary(lines, stepLabels),
1035
- children: [
1036
- [1, 0.5, 0].map((level) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1037
- "line",
1038
- {
1039
- x1: 0,
1040
- x2: width,
1041
- y1: height * (1 - level),
1042
- y2: height * (1 - level),
1043
- stroke: mark("survival.grid"),
1044
- strokeWidth: 0.6,
1045
- vectorEffect: "non-scaling-stroke"
1046
- },
1047
- level
1048
- )),
1049
- stepLabels.map((label, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1050
- "rect",
1051
- {
1052
- x: i === 0 ? 0 : x(i) - width / (stepLabels.length - 1) / 2,
1053
- y: 0,
1054
- width: width / (stepLabels.length - 1) / (i === 0 || i === stepLabels.length - 1 ? 2 : 1),
1055
- height,
1056
- fill: "transparent",
1057
- onMouseEnter: () => {
1058
- setSteppedByKey(false);
1059
- setActive(i);
1060
- },
1061
- onMouseLeave: () => setActive(null)
1062
- },
1063
- label
1064
- )),
1065
- active !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1066
- "line",
1067
- {
1068
- x1: x(active),
1069
- x2: x(active),
1070
- y1: 0,
1071
- y2: height,
1072
- stroke: mark("survival.grid"),
1073
- strokeWidth: 1
1074
- }
1075
- ),
1076
- lines.map(({ segment, points, colour }) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1077
- "polyline",
1078
- {
1079
- points: points.map(([px, py]) => `${px},${py}`).join(" "),
1080
- fill: "none",
1081
- stroke: colour,
1082
- strokeWidth: 1.6,
1083
- vectorEffect: "non-scaling-stroke",
1084
- strokeLinejoin: "round"
1085
- },
1086
- segment.key
1087
- )),
1088
- active !== null && lines.map(({ segment, points, colour }) => {
1089
- const point = points[active];
1090
- if (!point) return null;
1091
- return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1092
- "line",
1093
- {
1094
- x1: point[0],
1095
- x2: point[0],
1096
- y1: point[1],
1097
- y2: point[1],
1098
- stroke: colour,
1099
- strokeWidth: 6,
1100
- strokeLinecap: "round",
1101
- vectorEffect: "non-scaling-stroke"
1102
- },
1103
- segment.key
1104
- );
1105
- })
1106
- ]
1107
- }
1108
- )
1109
- ] }),
1110
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: survivalAxis, children: stepLabels.map((label, i) => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(
1111
- "button",
1112
- {
1113
- type: "button",
1114
- style: survivalTick(i === active, i, stepLabels.length),
1115
- "aria-label": `Read ${label}`,
1116
- onMouseEnter: () => {
1117
- setSteppedByKey(false);
1118
- setActive(i);
1119
- },
1120
- onMouseLeave: () => setActive(null),
1121
- onFocus: () => {
1122
- setSteppedByKey(true);
1123
- setActive(i);
1124
- },
1125
- onBlur: () => setActive(null),
1126
- children: label
1127
- },
1128
- label
1129
- )) }),
1130
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: survivalLegend, "aria-live": steppedByKey ? "polite" : "off", children: lines.map(({ segment, shares, colour }) => {
1131
- const share = active === null ? null : shares[active];
1132
- return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, children: [
1133
- /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", { style: { ...hoverSwatch, background: colour, display: "inline-block", marginRight: 6 } }),
1134
- segment.label,
1135
- active !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("strong", { style: survivalReadoutValue, children: [
1136
- "\xA0",
1137
- share === null || share === void 0 ? "not measured here" : formatPercent(share, 0)
1057
+ function LicenceLadder({ rows, currency }) {
1058
+ if (rows.length === 0) return null;
1059
+ const peak = Math.max(1, ...rows.map((row) => row.orders));
1060
+ const types = [];
1061
+ for (const row of rows) {
1062
+ const facet = types.find((t) => t.type === row.type);
1063
+ if (facet) facet.rungs.push(row);
1064
+ else types.push({ type: row.type, rungs: [row] });
1065
+ }
1066
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Stack, { space: 4, children: types.map((facet) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Stack, { space: 2, children: [
1067
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 1, weight: "semibold", children: facet.type }),
1068
+ facet.rungs.map((row, index) => {
1069
+ const sameTierAsAbove = index > 0 && facet.rungs[index - 1]?.tier === row.tier;
1070
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { style: ladderRow, children: [
1071
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: sameTierAsAbove ? "" : row.tier }),
1072
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: row.term }),
1073
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { "aria-hidden": "true", style: ladderTrack, children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", { style: { ...barFill, width: `${Math.max(2, row.orders / peak * 100)}%` } }) }),
1074
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(import_sanity_ui_compat.Text, { size: 0, style: ladderValue, children: [
1075
+ formatCount(row.orders),
1076
+ row.revenue !== null && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", { style: ladderMoney, children: [
1077
+ " \xB7 ",
1078
+ formatMoney(row.revenue, currency)
1079
+ ] })
1138
1080
  ] })
1139
- ] }, segment.key);
1140
- }) })
1141
- ] });
1142
- }
1143
- function segmentColour(index) {
1144
- const names = ["survival.line", "survival.line.2", "survival.line.3", "survival.line.4"];
1145
- return mark(names[index % names.length]);
1146
- }
1147
- function survivalSummary(lines, stepLabels) {
1148
- const parts = lines.map(({ segment, shares }) => {
1149
- const last = shares.length - 1;
1150
- const share = shares[last];
1151
- const reached = stepLabels[last] ?? "the last measured step";
1152
- return share === null || share === void 0 ? `${segment.label} could not be followed past ${reached}` : `${segment.label} ${formatPercent(share, 0)} by ${reached}`;
1153
- });
1154
- return `Share of each segment still present at each step, starting from ${stepLabels[0]}. ${parts.join(". ")}.`;
1081
+ ] }, `${row.tier}\0${row.term}`);
1082
+ })
1083
+ ] }, facet.type)) });
1155
1084
  }
1156
1085
  function RatioFigure({
1157
1086
  value,
@@ -1428,82 +1357,6 @@ var estimateDot = {
1428
1357
  borderRadius: "50%",
1429
1358
  background: mark("estimate.dot")
1430
1359
  };
1431
- var shiftTrack = {
1432
- position: "relative",
1433
- height: 16,
1434
- borderRadius: 2,
1435
- background: mark("bar.track")
1436
- };
1437
- var shiftLink = {
1438
- position: "absolute",
1439
- top: 7,
1440
- height: 2,
1441
- background: mark("shift.link")
1442
- };
1443
- var shiftBefore = {
1444
- position: "absolute",
1445
- top: 3,
1446
- width: 10,
1447
- height: 10,
1448
- marginLeft: -5,
1449
- borderRadius: "50%",
1450
- border: `2px solid ${mark("shift.before")}`,
1451
- background: "transparent"
1452
- };
1453
- var shiftNow = {
1454
- position: "absolute",
1455
- top: 3,
1456
- width: 10,
1457
- height: 10,
1458
- marginLeft: -5,
1459
- borderRadius: "50%",
1460
- background: mark("shift.now")
1461
- };
1462
- var survivalLegend = { display: "flex", gap: 14, flexWrap: "wrap" };
1463
- var survivalFrame = { paddingLeft: 34, position: "relative" };
1464
- var survivalAxis = {
1465
- position: "relative",
1466
- // Reserves the row's height, since the ticks inside it are positioned.
1467
- height: 30,
1468
- marginLeft: 34
1469
- };
1470
- function survivalTick(activeTick, index, count) {
1471
- const at = count > 1 ? index / (count - 1) * 100 : 50;
1472
- return {
1473
- position: "absolute",
1474
- left: `${at}%`,
1475
- // The ends align to the plot edge; everything between is centred on its vertex.
1476
- transform: index === 0 ? "none" : index === count - 1 ? "translateX(-100%)" : "translateX(-50%)",
1477
- appearance: "none",
1478
- background: "transparent",
1479
- border: "none",
1480
- borderTop: `2px solid ${activeTick ? "currentColor" : "transparent"}`,
1481
- color: "inherit",
1482
- opacity: activeTick ? 1 : 0.7,
1483
- font: "inherit",
1484
- fontSize: "0.72em",
1485
- fontWeight: activeTick ? 600 : 400,
1486
- padding: "6px 4px",
1487
- minHeight: 24,
1488
- cursor: "pointer",
1489
- whiteSpace: "nowrap"
1490
- };
1491
- }
1492
- function survivalYLabel(level) {
1493
- return {
1494
- position: "absolute",
1495
- left: 0,
1496
- // 150 is the plot's rendered height; the label is nudged up by half its own line so it sits
1497
- // on the rule rather than under it.
1498
- top: 150 * (1 - level) - 6,
1499
- width: 28,
1500
- textAlign: "right",
1501
- fontSize: 10,
1502
- opacity: 0.7,
1503
- fontVariantNumeric: "tabular-nums"
1504
- };
1505
- }
1506
- var survivalReadoutValue = { fontVariantNumeric: "tabular-nums" };
1507
1360
  var ratioRow = {
1508
1361
  display: "flex",
1509
1362
  alignItems: "baseline",
@@ -3164,33 +3017,25 @@ function JourneyPanel({ data }) {
3164
3017
  label: step.label,
3165
3018
  value: step.count.value,
3166
3019
  conversionFromPrevious: step.conversionFromPrevious,
3020
+ // The device split, subdividing this rung's bar rather than drawn as a second chart.
3021
+ // Only counts that are present and whole travel: a segment GA4 stopped reporting is
3022
+ // left out of the split rather than drawn as a shrunken share of one.
3023
+ ...segments.length > 0 ? {
3024
+ segments: segments.flatMap((segment) => {
3025
+ const cell = segment.steps.find((s) => s.key === step.key);
3026
+ const value = cell ? metricSortValue(cell.count) : null;
3027
+ return value === null ? [] : [{ key: segment.key, label: segment.label, value }];
3028
+ })
3029
+ } : {},
3167
3030
  ...step.count.status === "partial" && step.count.coveredFrom ? { partial: { from: formatDay(step.count.coveredFrom) } } : {}
3168
3031
  }]
3169
3032
  );
3170
- const wholeSpine = spine.filter((step) => {
3171
- const cell = allSteps.find((s) => s.key === step.key);
3172
- return cell?.count.status !== "partial";
3173
- });
3174
3033
  const tracked = data.measurement === "sequence";
3175
3034
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
3176
3035
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Card, { padding: 3, radius: 2, tone: tracked ? "transparent" : "caution", border: true, children: /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 2, children: [
3177
3036
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, weight: "medium", children: tracked ? "Tracked path" : "Not a tracked path" }),
3178
3037
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: tracked, children: data.approximationNote })
3179
3038
  ] }) }),
3180
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3181
- SurvivalLines,
3182
- {
3183
- segments: segments.map((segment) => ({
3184
- key: segment.key,
3185
- label: segment.label,
3186
- values: wholeSpine.map((step) => {
3187
- const cell = segment.steps.find((s) => s.key === step.key);
3188
- return cell ? metricSortValue(cell.count) : null;
3189
- })
3190
- })),
3191
- stepLabels: wholeSpine.map((step) => step.label)
3192
- }
3193
- ),
3194
3039
  segments.length >= 2 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3195
3040
  ChartData,
3196
3041
  {
@@ -3489,22 +3334,8 @@ function TypefaceInterestPanel({ data }) {
3489
3334
  ] }),
3490
3335
  (data.licences?.length ?? 0) > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 3, children: [
3491
3336
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(SectionTitle, { title: "How licences sell" }),
3492
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "From your orders, so exact. Tier and term are separate rows, because they are the two variables in the pricing question and a tier that sells well at one year may not at perpetual." }),
3493
- /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3494
- ProportionChart,
3495
- {
3496
- bars: (data.licences ?? []).map((row) => ({
3497
- key: `${row.type}-${row.tier}-${row.term}`,
3498
- label: `${row.type} \xB7 ${row.tier}`,
3499
- sublabel: row.term,
3500
- // Ranked by revenue where it is known, by orders where it is not — a
3501
- // count would put a cheap tier above one worth ten times as much.
3502
- value: row.revenue ?? row.orders
3503
- })),
3504
- format: (value) => (data.licences ?? []).some((r) => r.revenue !== null) ? formatMoney(value, data.currency ?? null) : `${formatCount(value)} orders`,
3505
- totalLabel: (data.licences ?? []).some((r) => r.revenue !== null) ? "Total across licences" : "Total licence lines"
3506
- }
3507
- )
3337
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: true, children: "From your orders, so exact. Bars are orders; the money beside them is that order total split evenly across the licences on it, so it is a share rather than a price." }),
3338
+ /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(LicenceLadder, { rows: data.licences ?? [], currency: data.currency ?? null })
3508
3339
  ] }),
3509
3340
  data.revenueIsApportioned && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: "An order covering several families is split evenly between them, because the order documents carry no per-line value." }),
3510
3341
  finiteOrNull(data.unattributedRevenue) !== null && data.unattributedRevenue > 0 && /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Text, { size: 0, muted: true, children: [