{"name":"data-quality","type":"registry:ui","files":[{"path":"data-quality.tsx","type":"registry:ui","content":"import { escapeHtml } from \"@/components/ui/charts/tooltip\";\nimport type { CustomSeriesOption } from \"echarts/charts\";\nimport type { ImagePatternObject } from \"echarts/core\";\nimport * as echarts from \"echarts/core\";\n\nexport const DATA_STATUSES = [\"measured\", \"estimated\", \"provisional\", \"forecast\", \"missing\"] as const;\nexport type DataStatus = (typeof DATA_STATUSES)[number];\n\nexport type DataStatusText = {\n  statuses?: Partial<Record<DataStatus, { label?: string; description?: string }>>;\n  keyLabel?: string;\n  rangeLabel?: string;\n  rangeDescription?: string;\n  noValueLabel?: string;\n  notShownLabel?: (count: number) => string;\n};\n\nexport type ResolvedDataStatusText = {\n  statuses: Record<DataStatus, { label: string; description: string }>;\n  keyLabel: string;\n  rangeLabel: string;\n  rangeDescription: string;\n  noValueLabel: string;\n  notShownLabel: (count: number) => string;\n};\n\nconst DEFAULT_TEXT: ResolvedDataStatusText = {\n  statuses: {\n    measured: { label: \"Measured\", description: \"Recorded from the source.\" },\n    estimated: {\n      label: \"Estimated\",\n      description: \"Calculated or modelled, not recorded directly.\",\n    },\n    provisional: {\n      label: \"Provisional\",\n      description: \"Incomplete or unconfirmed. This value can still change.\",\n    },\n    forecast: {\n      label: \"Forecast\",\n      description: \"A projection for a period that has not been recorded yet.\",\n    },\n    missing: { label: \"Missing\", description: \"No value was recorded for this point.\" },\n  },\n  keyLabel: \"Data status\",\n  rangeLabel: \"Range\",\n  rangeDescription: \"The span between the lower and upper bounds supplied with the value.\",\n  noValueLabel: \"No value\",\n  notShownLabel: (count) => `${count} not shown`,\n};\n\nexport function resolveDataStatusText(text?: DataStatusText): ResolvedDataStatusText {\n  if (!text) return DEFAULT_TEXT;\n  const statuses = { ...DEFAULT_TEXT.statuses };\n  for (const status of DATA_STATUSES) {\n    statuses[status] = { ...DEFAULT_TEXT.statuses[status], ...text.statuses?.[status] };\n  }\n  return {\n    statuses,\n    keyLabel: text.keyLabel ?? DEFAULT_TEXT.keyLabel,\n    rangeLabel: text.rangeLabel ?? DEFAULT_TEXT.rangeLabel,\n    rangeDescription: text.rangeDescription ?? DEFAULT_TEXT.rangeDescription,\n    noValueLabel: text.noValueLabel ?? DEFAULT_TEXT.noValueLabel,\n    notShownLabel: text.notShownLabel ?? DEFAULT_TEXT.notShownLabel,\n  };\n}\n\nexport type DataQualityKeys = {\n  statusKey?: string;\n  lowerKey?: string;\n  upperKey?: string;\n};\n\nexport type PointRange = { lower: number; upper: number };\n\n// `status: null` means the row made no claim. It is never promoted to \"measured\".\nexport type PointQuality = {\n  value: number | null;\n  status: DataStatus | null;\n  range: PointRange | null;\n};\n\nfunction readNumber(raw: unknown): number | null {\n  if (raw === null || raw === undefined || raw === \"\") return null;\n  const parsed = Number(raw);\n  return Number.isFinite(parsed) ? parsed : null;\n}\n\nfunction isDataStatus(raw: unknown): raw is DataStatus {\n  return typeof raw === \"string\" && (DATA_STATUSES as readonly string[]).includes(raw);\n}\n\nexport function readSeriesQuality(\n  data: Record<string, unknown>[],\n  dataKey: string,\n  keys: DataQualityKeys,\n): { points: PointQuality[]; issues: string[] } {\n  const { statusKey, lowerKey, upperKey } = keys;\n  const issues: string[] = [];\n  const hasBothBounds = Boolean(lowerKey && upperKey);\n  if (Boolean(lowerKey) !== Boolean(upperKey)) {\n    issues.push(`\"${dataKey}\": set lowerKey and upperKey together. The single bound is ignored.`);\n  }\n\n  const points = data.map((row, index): PointQuality => {\n    let value = readNumber(row[dataKey]);\n\n    let status: DataStatus | null = null;\n    const rawStatus = statusKey ? row[statusKey] : undefined;\n    if (isDataStatus(rawStatus)) {\n      status = rawStatus;\n    } else if (rawStatus !== undefined && rawStatus !== null) {\n      issues.push(\n        `\"${dataKey}\" row ${index}: ${JSON.stringify(rawStatus)} is not a data status. The point is drawn without one.`,\n      );\n    }\n\n    if (status === \"missing\" && value !== null) {\n      issues.push(\n        `\"${dataKey}\" row ${index}: marked missing but has the value ${value}. The value is not drawn.`,\n      );\n      value = null;\n    }\n\n    let range: PointRange | null = null;\n    if (hasBothBounds && status !== \"missing\") {\n      const lower = readNumber(row[lowerKey as string]);\n      const upper = readNumber(row[upperKey as string]);\n      if (lower !== null && upper !== null) {\n        const containsValue = value === null || (lower <= value && value <= upper);\n        if (lower <= upper && containsValue) range = { lower, upper };\n        else {\n          issues.push(\n            `\"${dataKey}\" row ${index}: bounds ${lower} and ${upper} are out of order or exclude the value. The range is not drawn.`,\n          );\n        }\n      } else if (lower !== null || upper !== null) {\n        issues.push(`\"${dataKey}\" row ${index}: only one bound is set. The range is not drawn.`);\n      }\n    }\n\n    return { value, status, range };\n  });\n\n  return { points, issues };\n}\n\nexport type ChartDataQuality = {\n  points: Record<string, PointQuality[]>;\n  issues: string[];\n  summary: DataQualitySummary;\n};\n\nexport function collectDataQuality(\n  data: Record<string, unknown>[],\n  series: { dataKey: string; quality: DataQualityKeys | null }[],\n  options: { dropRanges?: boolean } = {},\n): ChartDataQuality {\n  const points: Record<string, PointQuality[]> = {};\n  const issues: string[] = [];\n  for (const { dataKey, quality } of series) {\n    if (!quality) continue;\n    const result = readSeriesQuality(data, dataKey, quality);\n    points[dataKey] = options.dropRanges\n      ? result.points.map((point) => ({ ...point, range: null }))\n      : result.points;\n    issues.push(...result.issues);\n  }\n  return { points, issues, summary: summarizeDataQuality(Object.values(points)) };\n}\n\nexport function dataQualityKeys(props: DataQualityKeys): DataQualityKeys | null {\n  if (!props.statusKey && !props.lowerKey && !props.upperKey) return null;\n  return { statusKey: props.statusKey, lowerKey: props.lowerKey, upperKey: props.upperKey };\n}\n\n// A share of a total is unknown when any part of that total is unknown, so a\n// row with a missing value has no total.\nexport function shareTotals(\n  data: Record<string, unknown>[],\n  seriesKeys: string[],\n  quality: Record<string, PointQuality[]>,\n): (number | null)[] {\n  return data.map((row, i) => {\n    let total = 0;\n    for (const key of seriesKeys) {\n      const points = quality[key];\n      const value = points ? points[i].value : Number(row[key]) || 0;\n      if (value === null) return null;\n      total += value;\n    }\n    return total;\n  });\n}\n\nconst MAX_REPORTED_ISSUES = 5;\n\nexport function reportDataQualityIssues(chartName: string, issues: string[]): void {\n  if (!issues.length) return;\n  if (typeof process !== \"undefined\" && process.env.NODE_ENV === \"production\") return;\n  const shown = issues.slice(0, MAX_REPORTED_ISSUES).join(\"\\n  \");\n  const rest = issues.length - MAX_REPORTED_ISSUES;\n  console.warn(`[${chartName}] Data status input:\\n  ${shown}${rest > 0 ? `\\n  …and ${rest} more.` : \"\"}`);\n}\n\nexport type StrokeStatus = \"plain\" | \"estimated\" | \"provisional\" | \"forecast\";\nexport type StrokeRun = { stroke: StrokeStatus; start: number; end: number };\n\nfunction strokeOf(point: PointQuality): StrokeStatus {\n  const { status } = point;\n  return status === \"estimated\" || status === \"provisional\" || status === \"forecast\"\n    ? status\n    : \"plain\";\n}\n\n// A segment is solid only when both of its ends are. Otherwise it takes the\n// status of its later point, or of its earlier point when the later one is solid.\nexport function strokeRuns(points: PointQuality[]): StrokeRun[] {\n  const runs: StrokeRun[] = [];\n  for (let i = 0; i < points.length - 1; i++) {\n    const from = points[i];\n    const to = points[i + 1];\n    if (from.value === null || to.value === null) continue;\n    const later = strokeOf(to);\n    const stroke = later !== \"plain\" ? later : strokeOf(from);\n    const last = runs[runs.length - 1];\n    if (last && last.end === i && last.stroke === stroke) last.end = i + 1;\n    else runs.push({ stroke, start: i, end: i + 1 });\n  }\n  return runs;\n}\n\nconst UNFOCUSED_RUN_OPACITY = 0.3;\n\n// Run strokes hold one opacity across the emphasis and blur states, because the\n// axis pointer emphasises every series at once. Hover dimming is patched in here.\nexport function runFocusPatch(\n  companionIdsByKey: Map<string, string[]>,\n  hoveredKey: string | null,\n  strokeOpacity: (dataKey: string) => number,\n) {\n  const patch = [];\n  for (const [dataKey, ids] of companionIdsByKey) {\n    const unfocused = hoveredKey !== null && hoveredKey !== dataKey;\n    const opacity = strokeOpacity(dataKey) * (unfocused ? UNFOCUSED_RUN_OPACITY : 1);\n    for (const id of ids) {\n      if (!isQualityRunId(id)) continue;\n      patch.push({\n        id,\n        lineStyle: { opacity },\n        emphasis: { lineStyle: { opacity } },\n        blur: { lineStyle: { opacity } },\n      });\n    }\n  }\n  return patch;\n}\n\nexport function runValues(values: (number | null)[], run: StrokeRun): (number | null)[] {\n  return values.map((value, i) => (i >= run.start && i <= run.end ? value : null));\n}\n\nexport const STROKE_DASH: Record<StrokeStatus, \"solid\" | [number, number]> = {\n  plain: \"solid\",\n  estimated: [1, 3],\n  provisional: [4, 3],\n  forecast: [9, 4],\n};\n\nconst QUALITY_RUN_PREFIX = \"__quality-\";\nconst QUALITY_RANGE_PREFIX = \"__range-\";\nconst QUALITY_MISSING_PREFIX = \"__missing-\";\n\nexport function qualityRunId(dataKey: string, runIndex: number): string {\n  return `${QUALITY_RUN_PREFIX}${runIndex}-${dataKey}`;\n}\n\nexport function qualityRangeId(dataKey: string): string {\n  return `${QUALITY_RANGE_PREFIX}${dataKey}`;\n}\n\nexport function isQualityRunId(seriesId: string): boolean {\n  return seriesId.startsWith(QUALITY_RUN_PREFIX);\n}\n\n// The data key of the series that a run or range companion belongs to.\nexport function qualitySeriesOwner(seriesId: string): string | null {\n  if (seriesId.startsWith(QUALITY_RANGE_PREFIX)) return seriesId.slice(QUALITY_RANGE_PREFIX.length);\n  if (seriesId.startsWith(QUALITY_MISSING_PREFIX)) {\n    return seriesId.slice(QUALITY_MISSING_PREFIX.length);\n  }\n  return seriesId.match(/^__quality-\\d+-(.*)$/)?.[1] ?? null;\n}\n\nconst WHISKER_WIDTH = 1.25;\nconst WHISKER_CAP = 3;\n\ntype RenderItem = NonNullable<CustomSeriesOption[\"renderItem\"]>;\nexport type RenderItemApi = Parameters<RenderItem>[1];\n\nexport type PlottedRange = { index: number; lower: number; upper: number };\n\nexport function rangeWhiskerSeries(params: {\n  dataKey: string;\n  ranges: PlottedRange[];\n  color: string;\n  opacity: number;\n  z: number;\n  isHorizontal?: boolean;\n  categoryOffset?: (api: RenderItemApi) => number;\n}): CustomSeriesOption {\n  const { dataKey, ranges, color, opacity, z, isHorizontal = false, categoryOffset } = params;\n  const style = { stroke: color, lineWidth: WHISKER_WIDTH, opacity };\n  const line = (x1: number, y1: number, x2: number, y2: number) => ({\n    type: \"line\" as const,\n    shape: { x1, y1, x2, y2 },\n    style,\n    emphasis: { style },\n  });\n\n  return {\n    id: qualityRangeId(dataKey),\n    type: \"custom\",\n    data: ranges.map(({ index, lower, upper }) => [index, lower, upper]),\n    encode: isHorizontal ? { y: 0, x: [1, 2] } : { x: 0, y: [1, 2] },\n    silent: true,\n    clip: true,\n    z,\n    tooltip: { show: false },\n    renderItem: (_params, api) => {\n      const index = api.value(0);\n      const lower = api.value(1);\n      const upper = api.value(2);\n      const shift = categoryOffset?.(api) ?? 0;\n\n      if (isHorizontal) {\n        const [x1, baseY] = api.coord([lower, index]);\n        const [x2] = api.coord([upper, index]);\n        const y = baseY + shift;\n        return {\n          type: \"group\",\n          children: [\n            line(x1, y, x2, y),\n            line(x1, y - WHISKER_CAP, x1, y + WHISKER_CAP),\n            line(x2, y - WHISKER_CAP, x2, y + WHISKER_CAP),\n          ],\n        };\n      }\n\n      const [baseX, y1] = api.coord([index, lower]);\n      const [, y2] = api.coord([index, upper]);\n      const x = baseX + shift;\n      return {\n        type: \"group\",\n        children: [\n          line(x, y1, x, y2),\n          line(x - WHISKER_CAP, y1, x + WHISKER_CAP, y1),\n          line(x - WHISKER_CAP, y2, x + WHISKER_CAP, y2),\n        ],\n      };\n    },\n  };\n}\n\nconst STATUS_OUTLINE_WIDTH = 1;\n\nfunction statusPattern(kind: \"dots\" | \"hatch\", color: string): ImagePatternObject | null {\n  if (typeof document === \"undefined\") return null;\n  const dpr = Math.max(window.devicePixelRatio || 1, 1);\n  const canvas = document.createElement(\"canvas\");\n  const ctx = canvas.getContext(\"2d\");\n  if (!ctx) return null;\n  canvas.width = 5 * dpr;\n  canvas.height = 5 * dpr;\n  ctx.scale(dpr, dpr);\n  ctx.fillStyle = color;\n  if (kind === \"dots\") {\n    ctx.beginPath();\n    ctx.arc(2.5, 2.5, 0.9, 0, Math.PI * 2);\n    ctx.fill();\n  } else {\n    ctx.fillRect(0, 0, 1, 5);\n  }\n  return {\n    image: canvas,\n    repeat: \"repeat\",\n    rotation: kind === \"hatch\" ? -Math.PI / 4 : 0,\n    scaleX: 1 / dpr,\n    scaleY: 1 / dpr,\n  };\n}\n\nexport type StatusItemStyle = {\n  color: string | ImagePatternObject | echarts.graphic.LinearGradient | echarts.graphic.RadialGradient;\n  borderColor: string;\n  borderWidth: number;\n  borderType?: [number, number];\n};\n\nexport type StatusItemStyles = Partial<Record<DataStatus, StatusItemStyle>>;\n\n// Filled marks (bars and pie sectors): dotted for estimated, hatched for\n// provisional, and an empty dashed outline for forecast. Measured keeps its own fill.\nexport function statusFillStyles(base: string): StatusItemStyles {\n  const outline = { borderColor: base, borderWidth: STATUS_OUTLINE_WIDTH };\n  return {\n    estimated: { color: statusPattern(\"dots\", base) ?? \"transparent\", ...outline },\n    provisional: { color: statusPattern(\"hatch\", base) ?? \"transparent\", ...outline },\n    forecast: { color: \"transparent\", ...outline, borderType: [4, 3] },\n  };\n}\n\n// Point marks are too small for patterns: a ring with a centre dot for estimated,\n// half filled for provisional, and an empty ring for forecast.\nexport function statusPointStyles(base: string): StatusItemStyles {\n  const outline = { borderColor: base, borderWidth: 1.25 };\n  const clear = \"rgba(0, 0, 0, 0)\";\n  return {\n    estimated: {\n      color: new echarts.graphic.RadialGradient(0.5, 0.5, 0.5, [\n        { offset: 0, color: base },\n        { offset: 0.4, color: base },\n        { offset: 0.4, color: clear },\n        { offset: 1, color: clear },\n      ]),\n      ...outline,\n    },\n    provisional: {\n      color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [\n        { offset: 0, color: base },\n        { offset: 0.5, color: base },\n        { offset: 0.5, color: clear },\n        { offset: 1, color: clear },\n      ]),\n      ...outline,\n    },\n    forecast: { color: clear, ...outline },\n  };\n}\n\nconst CELL_STATUS_CODE: Partial<Record<DataStatus, number>> = {\n  estimated: 1,\n  provisional: 2,\n  forecast: 3,\n  missing: 4,\n};\nconst CELL_MARK_INSET = 3;\nconst CELL_HALO_WIDTH = 3;\n\n// A heatmap cell's colour is its value, so status is an overlay that leaves the\n// colour alone: a corner dot for estimated, a corner slash for provisional, a\n// dashed outline for forecast, and a dashed ring in the empty cell for missing.\n// Each mark is drawn over a halo so it stays visible on any cell colour.\nexport function cellStatusSeries(params: {\n  dataKey: string;\n  cells: { x: number; y: number; status: DataStatus | null }[];\n  foreground: string;\n  background: string;\n  z: number;\n}): CustomSeriesOption | null {\n  const { dataKey, cells, foreground, background, z } = params;\n  const marked = cells.flatMap((cell) => {\n    const code = cell.status ? CELL_STATUS_CODE[cell.status] : undefined;\n    return code ? [[cell.x, cell.y, code]] : [];\n  });\n  if (!marked.length) return null;\n\n  const stroke = (color: string, lineWidth: number, lineDash?: number[]) => ({\n    fill: \"none\",\n    stroke: color,\n    lineWidth,\n    lineDash,\n    lineCap: \"round\" as const,\n  });\n  const haloed = <T extends { style: unknown }>(make: (halo: boolean) => T): T[] => [\n    make(true),\n    make(false),\n  ];\n\n  return {\n    id: `__cellstatus-${dataKey}`,\n    type: \"custom\",\n    data: marked,\n    encode: { x: 0, y: 1 },\n    silent: true,\n    z,\n    tooltip: { show: false },\n    renderItem: (_params, api) => {\n      const [cx, cy] = api.coord([api.value(0), api.value(1)]);\n      const [width, height] = api.size?.([1, 1]) as number[];\n      const code = api.value(2);\n      const right = cx + width / 2 - CELL_MARK_INSET;\n      const top = cy - height / 2 + CELL_MARK_INSET;\n      const glyph = Math.max(4, Math.min(8, Math.min(width, height) * 0.28));\n\n      if (code === CELL_STATUS_CODE.estimated) {\n        const dot = { cx: right - glyph / 2, cy: top + glyph / 2 };\n        return {\n          type: \"group\",\n          children: [\n            { type: \"circle\", shape: { ...dot, r: glyph / 3 + 1.25 }, style: { fill: background } },\n            { type: \"circle\", shape: { ...dot, r: glyph / 3 }, style: { fill: foreground } },\n          ],\n        };\n      }\n\n      if (code === CELL_STATUS_CODE.provisional) {\n        const shape = { x1: right - glyph, y1: top + glyph, x2: right, y2: top };\n        return {\n          type: \"group\",\n          children: haloed((halo) => ({\n            type: \"line\" as const,\n            shape,\n            style: halo ? stroke(background, CELL_HALO_WIDTH) : stroke(foreground, WHISKER_WIDTH),\n          })),\n        };\n      }\n\n      if (code === CELL_STATUS_CODE.forecast) {\n        const shape = {\n          x: cx - width / 2 + CELL_MARK_INSET,\n          y: top,\n          width: Math.max(width - CELL_MARK_INSET * 2, 1),\n          height: Math.max(height - CELL_MARK_INSET * 2, 1),\n        };\n        return {\n          type: \"group\",\n          children: haloed((halo) => ({\n            type: \"rect\" as const,\n            shape,\n            style: halo\n              ? stroke(background, CELL_HALO_WIDTH)\n              : stroke(foreground, WHISKER_WIDTH, [3, 3]),\n          })),\n        };\n      }\n\n      return {\n        type: \"circle\",\n        shape: { cx, cy, r: MISSING_RADIUS },\n        style: stroke(foreground, WHISKER_WIDTH, MISSING_DASH),\n      };\n    },\n  };\n}\n\nconst MISSING_RADIUS = 3.5;\nconst MISSING_DASH = [2, 2];\nconst MISSING_INSET = 3;\n\n// A value that is marked missing has no height to plot, so its marker sits on\n// the category baseline: the slot is shown as empty instead of left blank.\n// `anchors` moves a marker to where its series would start in a stack.\nexport function missingMarkerSeries(params: {\n  dataKey: string;\n  points: PointQuality[];\n  color: string;\n  opacity: number;\n  z: number;\n  isHorizontal?: boolean;\n  categoryOffset?: (api: RenderItemApi) => number;\n  anchors?: number[] | null;\n}): CustomSeriesOption | null {\n  const { dataKey, points, color, opacity, z, isHorizontal = false, categoryOffset, anchors } =\n    params;\n  const indices = points.flatMap((point, index) => (point.status === \"missing\" ? [index] : []));\n  if (!indices.length) return null;\n  const style = {\n    fill: \"none\",\n    stroke: color,\n    lineWidth: WHISKER_WIDTH,\n    lineDash: MISSING_DASH,\n    opacity,\n  };\n\n  return {\n    id: `${QUALITY_MISSING_PREFIX}${dataKey}`,\n    type: \"custom\",\n    data: indices.map((index) => [index, anchors?.[index] ?? 0]),\n    encode: isHorizontal ? { y: 0 } : { x: 0 },\n    silent: true,\n    z,\n    tooltip: { show: false },\n    renderItem: (itemParams, api) => {\n      const index = api.value(0);\n      const grid = itemParams.coordSys as unknown as {\n        x: number;\n        y: number;\n        width: number;\n        height: number;\n      };\n      const shift = categoryOffset?.(api) ?? 0;\n      const anchor = api.value(1);\n      const reach = MISSING_RADIUS + MISSING_INSET;\n      const [anchorX, anchorY] = api.coord(isHorizontal ? [anchor, index] : [index, anchor]);\n      const [cx, cy] = isHorizontal\n        ? [(anchors ? anchorX : grid.x) + reach, anchorY + shift]\n        : [anchorX + shift, (anchors ? anchorY : grid.y + grid.height) - reach];\n      return {\n        type: \"circle\",\n        shape: { cx, cy, r: MISSING_RADIUS },\n        style,\n        emphasis: { style },\n      };\n    },\n  };\n}\n\n// Tooltip rows are assembled as HTML, so replacement text is escaped on the way in.\nexport function noValueHtml(text: ResolvedDataStatusText): string {\n  return escapeHtml(text.noValueLabel);\n}\n\nexport function statusLabelHtml(status: DataStatus | null, text: ResolvedDataStatusText): string {\n  return status ? escapeHtml(text.statuses[status].label) : \"\";\n}\n\nexport function statusDetailText(\n  point: PointQuality,\n  text: ResolvedDataStatusText,\n  formatValue: (value: number) => string,\n): string {\n  const parts: string[] = [];\n  if (point.status) parts.push(text.statuses[point.status].label);\n  if (point.range) {\n    parts.push(\n      `${text.rangeLabel} ${formatValue(point.range.lower)} – ${formatValue(point.range.upper)}`,\n    );\n  }\n  return parts.join(\" · \");\n}\n\n// \"Measured\" needs no caveat, so it is labelled on its row but not explained again.\nexport function statusNotesHtml(present: Set<DataStatus>, text: ResolvedDataStatusText): string {\n  const notes = DATA_STATUSES.filter((status) => status !== \"measured\" && present.has(status)).map(\n    (status) =>\n      `<div><span class=\"text-foreground font-medium\">${escapeHtml(text.statuses[status].label)}:</span> ${escapeHtml(text.statuses[status].description)}</div>`,\n  );\n  if (!notes.length) return \"\";\n  return `<div class=\"text-muted-foreground border-border/50 grid max-w-56 gap-1 border-t pt-1.5 leading-snug whitespace-normal\">${notes.join(\"\")}</div>`;\n}\n\nexport type DataQualitySummary = { statuses: DataStatus[]; hasRanges: boolean };\n\nexport function summarizeDataQuality(series: PointQuality[][]): DataQualitySummary {\n  const present = new Set<DataStatus>();\n  let hasRanges = false;\n  for (const points of series) {\n    for (const point of points) {\n      if (point.status) present.add(point.status);\n      if (point.range) hasRanges = true;\n    }\n  }\n  return { statuses: DATA_STATUSES.filter((status) => present.has(status)), hasRanges };\n}\n\nexport function dataQualityAriaSummary(\n  summary: DataQualitySummary,\n  text: ResolvedDataStatusText,\n): string {\n  if (!summary.statuses.length) return \"\";\n  const labels = summary.statuses.map((status) => text.statuses[status].label).join(\", \");\n  return ` ${text.keyLabel}: ${labels}.`;\n}\n\nexport type DataStatusMark = \"line\" | \"fill\" | \"point\" | \"cell\";\n\nconst SWATCH = \"h-2.5 w-5 shrink-0 overflow-visible\";\n\nconst NARROW_SWATCH = \"h-2.5 w-2.5 shrink-0 overflow-visible\";\n\nfunction MissingSwatch() {\n  return (\n    <svg aria-hidden viewBox=\"0 0 10 10\" className={NARROW_SWATCH} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.25\">\n      <circle cx=\"5\" cy=\"5\" r=\"4\" strokeDasharray={MISSING_DASH.join(\" \")} />\n    </svg>\n  );\n}\n\nfunction LineSwatch({ status }: { status: DataStatus }) {\n  if (status === \"missing\") return <MissingSwatch />;\n  const dash = STROKE_DASH[status === \"measured\" ? \"plain\" : status];\n  return (\n    <svg aria-hidden viewBox=\"0 0 20 10\" className={SWATCH} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\">\n      <path\n        d=\"M0 5h20\"\n        strokeDasharray={dash === \"solid\" ? undefined : dash.join(\" \")}\n        strokeLinecap={status === \"estimated\" ? \"round\" : undefined}\n      />\n    </svg>\n  );\n}\n\nfunction PointSwatch({ status }: { status: DataStatus }) {\n  if (status === \"missing\") return <MissingSwatch />;\n  return (\n    <svg aria-hidden viewBox=\"0 0 10 10\" className={NARROW_SWATCH} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.25\">\n      <circle cx=\"5\" cy=\"5\" r=\"4\" fill={status === \"measured\" ? \"currentColor\" : undefined} />\n      {status === \"estimated\" && <circle cx=\"5\" cy=\"5\" r=\"1.6\" fill=\"currentColor\" stroke=\"none\" />}\n      {status === \"provisional\" && <path d=\"M5 1a4 4 0 0 0 0 8z\" fill=\"currentColor\" stroke=\"none\" />}\n    </svg>\n  );\n}\n\nfunction CellSwatch({ status }: { status: DataStatus }) {\n  if (status === \"missing\") return <MissingSwatch />;\n  return (\n    <svg aria-hidden viewBox=\"0 0 10 10\" className={NARROW_SWATCH} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\">\n      <rect x=\"0\" y=\"0\" width=\"10\" height=\"10\" rx=\"1.5\" fill=\"currentColor\" fillOpacity=\"0.28\" stroke=\"none\" />\n      {status === \"estimated\" && <circle cx=\"7\" cy=\"3\" r=\"1.5\" fill=\"currentColor\" stroke=\"none\" />}\n      {status === \"provisional\" && <path d=\"M5 5.5l3.5-3.5\" strokeWidth=\"1.25\" strokeLinecap=\"round\" />}\n      {status === \"forecast\" && (\n        <rect x=\"1.5\" y=\"1.5\" width=\"7\" height=\"7\" strokeDasharray=\"2 1.5\" />\n      )}\n    </svg>\n  );\n}\n\nconst SWATCHES = { line: LineSwatch, fill: FillSwatch, point: PointSwatch, cell: CellSwatch };\n\nfunction FillSwatch({ status }: { status: DataStatus }) {\n  if (status === \"missing\") return <MissingSwatch />;\n  const frame = { x: 0.5, y: 0.5, width: 9, height: 9 };\n  return (\n    <svg aria-hidden viewBox=\"0 0 10 10\" className={NARROW_SWATCH} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1\">\n      {status === \"measured\" && <rect {...frame} fill=\"currentColor\" />}\n      {status === \"estimated\" && (\n        <>\n          <rect {...frame} />\n          <path d=\"M3 3h.01M7 3h.01M5 5h.01M3 7h.01M7 7h.01\" strokeWidth=\"1.5\" strokeLinecap=\"round\" />\n        </>\n      )}\n      {status === \"provisional\" && (\n        <>\n          <rect {...frame} />\n          <path d=\"M0.5 5.5l5-5M0.5 9.5l9-9M4.5 9.5l5-5\" />\n        </>\n      )}\n      {status === \"forecast\" && <rect {...frame} strokeDasharray=\"2.5 2\" />}\n    </svg>\n  );\n}\n\nfunction RangeSwatch() {\n  return (\n    <svg aria-hidden viewBox=\"0 0 10 10\" className={NARROW_SWATCH} fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.25\">\n      <path d=\"M5 0.5v9M2 0.5h6M2 9.5h6\" />\n    </svg>\n  );\n}\n\nexport function DataStatusKey({\n  summary,\n  mark,\n  text,\n  missingNote,\n}: {\n  summary: DataQualitySummary;\n  mark: DataStatusMark;\n  text: ResolvedDataStatusText;\n  // Names or a count for missing values that the chart has no place to draw.\n  missingNote?: string;\n}) {\n  if (!summary.statuses.length && !summary.hasRanges) return null;\n  const Swatch = SWATCHES[mark];\n  const itemClass = \"flex min-h-6 items-center gap-1.5\";\n\n  return (\n    <ul\n      aria-label={text.keyLabel}\n      className=\"text-muted-foreground m-0 flex shrink-0 list-none flex-wrap items-center gap-x-4 px-2 pt-1\"\n    >\n      {summary.statuses.map((status) => (\n        <li key={status} className={itemClass} title={text.statuses[status].description}>\n          <Swatch status={status} />\n          {text.statuses[status].label}\n          {status === \"missing\" && missingNote ? `: ${missingNote}` : null}\n          <span className=\"sr-only\">. {text.statuses[status].description}</span>\n        </li>\n      ))}\n      {summary.hasRanges && (\n        <li className={itemClass} title={text.rangeDescription}>\n          <RangeSwatch />\n          {text.rangeLabel}\n          <span className=\"sr-only\">. {text.rangeDescription}</span>\n        </li>\n      )}\n    </ul>\n  );\n}\n","target":"components/ui/data-quality.tsx"}],"dependencies":["echarts"],"registryDependencies":["https://www.honestui.com/r/tooltip.json"]}