SPB Git forge
15commits 1branches 0releases
29.7 MBsize
maindefault branch
10 days agolast push
TypeScript 36.3% Python 31.8% Go 18% JavaScript 9.8% Shell 1.9% SQL 1.4% CSS 0.5%
4.4 KB · 90 lines tsx
Raw Blame History
1'use client';23import { useMemo } from 'react';4import { fmt } from '@/lib/format';5import { useTime } from '@/lib/time';6import { AXIS_STYLE, TOOLTIP_STYLE, levelMarkArea, type BarSeriesOption, type EChartsOption, type LineSeriesOption } from './echarts';7import { useEChart } from './useEChart';89export interface SeriesLine {10  name: string;11  color: string;12  points: { ts: string; value: number | null }[];13  area?: boolean;14  width?: number;15  dashed?: boolean;16  yAxisIndex?: number;17  type?: 'line' | 'bar';18}1920/** Generic small multi-line chart (incident series vs global, ASN BGP series, target TTFB, baselines…). */21export function SeriesChart({ lines, height = 220, yMax = 100, yMin = 0, bands = true, y2, markLines, unit = '' }: { lines: SeriesLine[]; height?: number; yMax?: number | 'auto'; yMin?: number; bands?: boolean; y2?: { max?: number | 'auto'; name?: string }; markLines?: { ts: string; label: string }[]; unit?: string }) {22  const { mode, format } = useTime();23  const option = useMemo<EChartsOption | null>(() => {24    if (!lines.length) return null;25    return {26      animation: false,27      grid: { left: 40, right: y2 ? 40 : 12, top: 10, bottom: 26 },28      tooltip: {29        trigger: 'axis',30        ...TOOLTIP_STYLE,31        formatter: (params: unknown) => {32          const arr = params as { seriesName: string; value: [number, number | null]; color: string }[];33          if (!arr.length) return '';34          return `<div style="color:#8B98A5;margin-bottom:4px">${format(arr[0]!.value[0], 'short')}</div>` + arr.map((p) => `<div><span style="display:inline-block;width:8px;height:8px;background:${p.color};margin-right:6px"></span>${p.seriesName} <b style="float:right;margin-left:12px">${p.value[1] == null ? '—' : fmt(p.value[1], Math.abs(p.value[1]) >= 100 ? 0 : 1)}${unit}</b></div>`).join('');35        },36      },37      xAxis: { type: 'time', ...AXIS_STYLE, splitLine: { show: false }, axisLabel: { ...AXIS_STYLE.axisLabel, formatter: (v: number) => format(v, 'time').replace(' UTC', '').slice(0, 5) } },38      yAxis: [39        { type: 'value', min: yMin, max: yMax === 'auto' ? undefined : yMax, ...AXIS_STYLE },40        ...(y2 ? [{ type: 'value' as const, min: 0, max: y2.max === 'auto' ? undefined : y2.max, ...AXIS_STYLE, splitLine: { show: false }, name: y2.name, nameTextStyle: { color: '#8B98A5', fontSize: 10 } }] : []),41      ],42      series: lines.map((l, i): LineSeriesOption | BarSeriesOption => {43        const data = l.points.map((p) => [new Date(p.ts).getTime(), p.value]);44        const markLine: LineSeriesOption['markLine'] =45          i === 0 && markLines?.length46            ? {47                symbol: 'none',48                silent: true,49                lineStyle: { color: '#5B6875', type: 'dotted' },50                label: { color: '#8B98A5', fontSize: 10, formatter: (p) => String((p as { name?: string }).name ?? ''), position: 'insideEndTop' },51                data: markLines.map((m) => ({ xAxis: new Date(m.ts).getTime(), name: m.label })),52              }53            : undefined;54        if (l.type === 'bar') {55          return { name: l.name, type: 'bar', yAxisIndex: l.yAxisIndex ?? 0, data, itemStyle: { color: l.color, opacity: 0.8 }, barMaxWidth: 6, z: lines.length - i };56        }57        return {58          name: l.name,59          type: 'line',60          yAxisIndex: l.yAxisIndex ?? 0,61          data,62          showSymbol: false,63          lineStyle: { width: l.width ?? 1.25, color: l.color, type: l.dashed ? 'dashed' : 'solid' },64          itemStyle: { color: l.color },65          areaStyle: l.area ? { color: l.color + '14' } : undefined,66          connectNulls: false,67          z: lines.length - i,68          markArea: i === 0 && bands ? levelMarkArea(0.05) : undefined,69          markLine,70        };71      }),72    };73    // eslint-disable-next-line react-hooks/exhaustive-deps74  }, [lines, yMax, yMin, bands, y2, markLines, unit, mode]);75  const { ref } = useEChart(option);76  return (77    <div>78      <div ref={ref} style={{ height }} className="w-full" role="img" aria-label={lines.map((l) => l.name).join(', ')} />79      <ul className="mt-1 flex flex-wrap gap-x-4 text-[10.5px] text-ink-2">80        {lines.map((l) => (81          <li key={l.name} className="inline-flex items-center gap-1.5">82            <span className="inline-block h-[2px] w-3" style={{ background: l.color, borderTop: l.dashed ? `1px dashed ${l.color}` : undefined }} aria-hidden="true" />83            {l.name}84          </li>85        ))}86      </ul>87    </div>88  );89}90