spb/internetpressure
Public
TypeScript 36.3%
Python 31.8%
Go 18%
JavaScript 9.8%
Shell 1.9%
SQL 1.4%
CSS 0.5%
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