@liiift-studio/sanity-visitor-insights 0.74.0 → 0.75.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
@@ -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
@@ -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,7 @@ 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 };
767
817
  var funnelList = { listStyle: "none", margin: 0, padding: 0 };
768
818
  var funnelItem = { display: "grid", gap: 4 };
769
819
  function funnelGapStyle(hasBar) {
@@ -989,170 +1039,6 @@ function EstimateDotPlot({
989
1039
  /* @__PURE__ */ (0, import_jsx_runtime.jsx)(import_sanity_ui_compat.Text, { size: 0, muted: true, children: "The rule marks 100%." })
990
1040
  ] });
991
1041
  }
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)
1138
- ] })
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(". ")}.`;
1155
- }
1156
1042
  function RatioFigure({
1157
1043
  value,
1158
1044
  format,
@@ -1428,82 +1314,6 @@ var estimateDot = {
1428
1314
  borderRadius: "50%",
1429
1315
  background: mark("estimate.dot")
1430
1316
  };
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
1317
  var ratioRow = {
1508
1318
  display: "flex",
1509
1319
  alignItems: "baseline",
@@ -3164,33 +2974,25 @@ function JourneyPanel({ data }) {
3164
2974
  label: step.label,
3165
2975
  value: step.count.value,
3166
2976
  conversionFromPrevious: step.conversionFromPrevious,
2977
+ // The device split, subdividing this rung's bar rather than drawn as a second chart.
2978
+ // Only counts that are present and whole travel: a segment GA4 stopped reporting is
2979
+ // left out of the split rather than drawn as a shrunken share of one.
2980
+ ...segments.length > 0 ? {
2981
+ segments: segments.flatMap((segment) => {
2982
+ const cell = segment.steps.find((s) => s.key === step.key);
2983
+ const value = cell ? metricSortValue(cell.count) : null;
2984
+ return value === null ? [] : [{ key: segment.key, label: segment.label, value }];
2985
+ })
2986
+ } : {},
3167
2987
  ...step.count.status === "partial" && step.count.coveredFrom ? { partial: { from: formatDay(step.count.coveredFrom) } } : {}
3168
2988
  }]
3169
2989
  );
3170
- const wholeSpine = spine.filter((step) => {
3171
- const cell = allSteps.find((s) => s.key === step.key);
3172
- return cell?.count.status !== "partial";
3173
- });
3174
2990
  const tracked = data.measurement === "sequence";
3175
2991
  return /* @__PURE__ */ (0, import_jsx_runtime3.jsxs)(import_sanity_ui_compat3.Stack, { space: 4, children: [
3176
2992
  /* @__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
2993
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, weight: "medium", children: tracked ? "Tracked path" : "Not a tracked path" }),
3178
2994
  /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(import_sanity_ui_compat3.Text, { size: 1, muted: tracked, children: data.approximationNote })
3179
2995
  ] }) }),
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
2996
  segments.length >= 2 && /* @__PURE__ */ (0, import_jsx_runtime3.jsx)(
3195
2997
  ChartData,
3196
2998
  {