LynxJS Data Visualization: กราฟ Sensor สวยๆ

LynxJS Data Visualization: กราฟ Sensor สวยๆ

ShowkhunWorkshop

LynxJS Data Visualization: กราฟ Sensor สวยๆ

Branch: step-13-mobile-charts Phase: Mobile (4/5) — Data Visualization Repo: kangana1024/showkhun-workshop


ตัวเลขดิบๆ บนการ์ดมันบอกได้แค่ “ตอนนี้เท่าไหร่” แต่บอกไม่ได้ว่า “มันกำลังจะไปทางไหน” — อุณหภูมิค่อยๆ ไต่ขึ้นทั้งวัน หรือพุ่งขึ้นพรวดเมื่อชั่วโมงที่แล้ว? คนเราอ่านกราฟเส้นได้เร็วกว่าตารางตัวเลขเยอะ วันนี้เราจะวาด line chart จากข้อมูล time-series จริงบนมือถือกัน (☞゚ヮ゚)☞

WHY ต้องสร้าง SVG เอง? Lynx ไม่มี chart library สำเร็จรูปอย่างเว็บ แต่มันมี <svg> element ที่รับ SVG string ผ่าน prop content ได้ เราเลย เขียน SVG builder ของเราเอง ที่ interpolate เฉพาะ “ตัวเลข” ลงไป — ปลอดภัยจาก injection และเบามาก ไม่ต้องลาก dependency หนักๆ บทความนี้อ้างอิงโค้ดจริงใน branch step-13-mobile-charts


ก่อนเริ่ม: น้องๆ จะได้อะไรจากบทนี้?

  • ดึง time-series readings ผ่าน GET /api/v1/devices/:id/readings (range/field/window/aggregate)
  • เข้าใจว่าทำไม backend clamp ค่า range/window/limit ให้ — client ส่งแค่ “เจตนา”
  • แปลง reading row → จุดบนกราฟ (sort ตามเวลา, กรองค่าที่ใช้ไม่ได้)
  • เขียน SVG line chart builder แบบ safe-by-construction
  • render ด้วย <svg content={svg} /> ของ Lynx ที่ scale ตามจอ
  • ทำตัวเลือก metric / time range / downsample window + สรุปสถิติ (min/max/last/count)

ภาพรวม: จาก InfluxDB มาเป็นเส้นกราฟ

graph LR
    A[(InfluxDB)] -->|GET /devices/:id/readings| B[useReadings]
    B -->|toPoints: sort + filter| C[TimePoint t,v]
    C -->|buildLineChartSvg| D[SVG string]
    D -->|svg content=| E[📈 LineChart บนจอ]

Step 1: useReadings — ดึงข้อมูลแบบ Bounded

readings endpoint คืน “flat projection” ของ row จาก InfluxDB เราประกาศ type ให้ตรง (api/types.ts):

// ReadingRow คือ projection แบนๆ ที่ readings endpoint คืนมา
export interface ReadingRow {
  time: string
  field: string
  value: number | string | boolean | null
  tags?: Record<string, unknown>
}

export interface ReadingMeta {
  bucket: string
  measurement: string
  range: string
  limit: number
  count: number
  field?: string
  window?: string
  aggregate?: string
}

hook useReadings (screens/useReadings.ts) ยิง request แล้วแปลง row เป็นจุดกราฟ จุดสำคัญคือ client ส่งแค่ “เจตนา” (อยากได้ range ไหน, window เท่าไหร่) ส่วนการ clamp ขอบเขตเป็นหน้าที่ backend:

export interface TimePoint {
  t: number   // epoch ms
  v: number   // ค่า numeric
}

/** ดึงค่า numeric ออกจาก row แล้ว sort ตามเวลา (น้อย→มาก) */
function toPoints(rows: ReadingRow[]): TimePoint[] {
  const points: TimePoint[] = []
  for (const row of rows) {
    const value = typeof row.value === 'number' ? row.value : Number(row.value)
    const time = Date.parse(row.time)
    if (Number.isFinite(value) && !Number.isNaN(time)) {
      points.push({ t: time, v: value })   // ทิ้งค่าที่ parse ไม่ได้
    }
  }
  points.sort((a, b) => a.t - b.t)
  return points
}

ตัว hook ใช้ trick ดีๆ คือ serialize params เป็น key เพื่อให้ effect re-run เฉพาะตอน “ค่าจริงเปลี่ยน” ไม่ใช่ทุก render:

export function useReadings(deviceId: string, params: ReadingsParams): ReadingsState {
  const [points, setPoints] = useState<TimePoint[]>([])
  const [meta, setMeta] = useState<ReadingMeta | null>(null)
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)
  const abortRef = useRef<AbortController | null>(null)

  // serialize params เพื่อให้ effect re-run เฉพาะตอนเปลี่ยนจริง
  const key = JSON.stringify({ deviceId, params })

  const load = useCallback(async () => {
    abortRef.current?.abort()
    const controller = new AbortController()
    abortRef.current = controller
    setLoading(true)
    setError(null)
    try {
      const env = await api.getReadings(deviceId, params, controller.signal)
      setPoints(toPoints(env.data))
      setMeta(env.meta)
    } catch (err) {
      if (controller.signal.aborted) return
      setError(err instanceof ApiError ? err.message : 'failed to load readings')
    } finally {
      if (!controller.signal.aborted) setLoading(false)
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [key])

  useEffect(() => {
    void load()
    return () => abortRef.current?.abort()
  }, [load])

  return { loading, error, points, meta, reload: load }
}

WHY ให้ backend clamp? เพราะถ้า client ขอ range 10 ปี window 1 วินาที มันจะลาก InfluxDB ตายและ response มหึมา backend ตั้งขอบเขตไว้ป้องกัน — client แค่ขอ “1 ชั่วโมง ละเอียดทุกนาที” ก็พอ


Step 2: SVG Builder — Safe by Construction

นี่คือหัวใจของบทนี้ครับ screens/chart.ts มีฟังก์ชัน buildLineChartSvg ที่รับจุดกราฟ + ขนาด + สี แล้วคืน SVG เป็น string

ก่อนอื่น มี guard เรื่องสี — validate ว่าค่าสีเป็น token ที่ปลอดภัยเท่านั้น (กันไม่ให้ string แปลกๆ หลุดเข้าไปใน attribute ของ SVG):

/**
 * validate CSS color token อนุญาตเฉพาะรูปแบบที่ปลอดภัย เพื่อให้ค่าสี
 * ไม่มีทาง break ออกจาก attribute ของ SVG ได้ (สีมาจาก theme ของแอปเอง
 * ไม่ใช่จาก server แต่ทำให้ builder ปลอดภัยตั้งแต่โครงสร้าง)
 */
function safeColor(color: string): string {
  return /^(#[0-9a-fA-F]{3,8}|rgba?\([\d.,\s]+\)|[a-zA-Z]+)$/.test(color)
    ? color
    : '#888888'
}

แล้ว builder ก็คำนวณ scale แล้ว interpolate เฉพาะตัวเลข ลงใน path/coordinate — ค่าจาก server (ที่เป็น number) จึง inject markup ไม่ได้เลย:

export function buildLineChartSvg(
  points: TimePoint[],
  dim: ChartDimensions,
  colors: ChartColors,
): string {
  const w = Math.max(1, Math.floor(dim.width))
  const h = Math.max(1, Math.floor(dim.height))
  const padX = 6, padY = 8
  const innerW = Math.max(1, w - padX * 2)
  const innerH = Math.max(1, h - padY * 2)

  const line = safeColor(colors.line)
  const fill = safeColor(colors.fill)
  const axis = safeColor(colors.axis)

  const baseline = `<line x1="${padX}" y1="${r(h - padY)}" x2="${r(w - padX)}" y2="${r(h - padY)}" stroke="${axis}" stroke-width="1" />`

  // จุดน้อยกว่า 2 = วาดเส้นไม่ได้ คืนแค่เส้นฐาน
  if (points.length < 2) {
    return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${baseline}</svg>`
  }

  // หาขอบเขตเวลา/ค่า เพื่อ map ลงพื้นที่วาด
  const tMin = points[0].t
  const tMax = points[points.length - 1].t
  const tSpan = tMax - tMin || 1
  let vMin = points[0].v, vMax = points[0].v
  for (const p of points) {
    if (p.v < vMin) vMin = p.v
    if (p.v > vMax) vMax = p.v
  }
  const vSpan = vMax - vMin || 1

  const x = (t: number) => padX + ((t - tMin) / tSpan) * innerW
  const y = (v: number) => padY + (1 - (v - vMin) / vSpan) * innerH

  // สร้าง path ของเส้น
  let d = ''
  for (let i = 0; i < points.length; i++) {
    d += `${i === 0 ? 'M' : 'L'}${r(x(points[i].t))} ${r(y(points[i].v))} `
  }

  // พื้นที่ใต้เส้น (area fill) + เส้น + เส้นฐาน
  const path = `<path d="${d.trim()}" fill="none" stroke="${line}" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" />`
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}">${path}${baseline}</svg>`
}

อุปมา safe-by-construction: เหมือนครัวที่ออกแบบให้ “ทำของไหม้ยาก” ตั้งแต่แรก ไม่ใช่ติดถังดับเพลิงทีหลัง — เราอนุญาตแค่ตัวเลขเข้า template ความเสี่ยง injection เลยหายไปตั้งแต่โครงสร้าง

ยังมี seriesStats คำนวณสรุปไว้โชว์ใต้กราฟด้วย:

export function seriesStats(points: TimePoint[]): SeriesStats | null {
  if (points.length === 0) return null
  let min = points[0].v, max = points[0].v
  for (const p of points) {
    if (p.v < min) min = p.v
    if (p.v > max) max = p.v
  }
  return { min, max, last: points[points.length - 1].v, count: points.length }
}

Step 3: LineChart — render SVG ด้วย Lynx

<svg> ของ Lynx รับ SVG string ผ่าน prop content แล้ว scale ตาม container ด้วย viewBox เราเลือกสีตาม theme (light/dark) ก่อนสร้าง:

// พื้นที่วาด nominal — viewBox ทำให้ scale ตามความกว้าง container ผ่าน CSS
const VIEW_WIDTH = 320
const VIEW_HEIGHT = 160

export function LineChart(props: LineChartProps) {
  const { theme } = useTheme()
  const colors =
    theme === 'dark'
      ? { line: '#5b8dff', fill: 'rgba(91,141,255,0.18)', axis: '#2a3548' }
      : { line: '#2f6df6', fill: 'rgba(47,109,246,0.12)', axis: '#dfe3ec' }

  const svg = buildLineChartSvg(props.points, { width: VIEW_WIDTH, height: VIEW_HEIGHT }, colors)

  return <svg className='LineChart' content={svg} />
}

แค่นี้แหละครับ — กราฟที่ scale ได้ทุกความกว้างจอ โดยไม่ต้องพึ่ง library


Step 4: DeviceCharts — เลือก Metric / Range / Window

หน้านี้ผูกทุกอย่างเข้าด้วยกัน user เลือก metric กับ range ได้ แล้วแต่ละ range จะมี downsample window ที่เหมาะสมติดมาด้วย (ช่วงยาว = window กว้าง เพื่อให้เส้นไม่รก):

interface RangeOption { value: string; label: string; window: string }

const RANGES: RangeOption[] = [
  { value: '1h',   label: '1H', window: '1m'  },
  { value: '6h',   label: '6H', window: '5m'  },
  { value: '24h',  label: '24H', window: '15m' },
  { value: '168h', label: '7D', window: '1h'  },
]

// field ตั้งต้นตามชนิด device
const FALLBACK_FIELDS_BY_TYPE: Record<string, string[]> = {
  temperature_humidity: ['temperature', 'humidity'],
  motion: ['motion'],
  relay: ['state'],
  gateway: ['rssi'],
}

ประกอบ params ส่งให้ useReadings (ใช้ useMemo กัน re-create object ทุก render):

export function DeviceCharts(props: DeviceChartsProps) {
  const fieldChoices = FALLBACK_FIELDS_BY_TYPE[props.deviceType] ?? ['temperature']
  const [field, setField] = useState<string>(fieldChoices[0])
  const [rangeValue, setRangeValue] = useState<string>('6h')

  const range = RANGES.find((r) => r.value === rangeValue) ?? RANGES[1]

  const params = useMemo<ReadingsParams>(
    () => ({
      range: range.value,
      field,
      window: range.window,
      aggregate: 'mean',   // downsample ด้วยค่าเฉลี่ยต่อ window
      limit: 500,
    }),
    [range.value, range.window, field],
  )

  const { loading, error, points, meta, reload } = useReadings(props.deviceId, params)
  const stats = seriesStats(points)
  // ...
}

เมื่อมีข้อมูลแล้ว render กราฟ + แถบสถิติ + บรรทัด meta ที่บอกว่า aggregate ยังไง:

<view>
  <LineChart points={points} />
  {stats ? (
    <view className='Charts__stats'>
      <view className='Charts__stat'>
        <text className='Charts__statValue'>{formatValue(stats.last)}</text>
        <text className='Charts__statLabel'>Latest</text>
      </view>
      <view className='Charts__stat'>
        <text className='Charts__statValue'>{formatValue(stats.min)}</text>
        <text className='Charts__statLabel'>Min</text>
      </view>
      <view className='Charts__stat'>
        <text className='Charts__statValue'>{formatValue(stats.max)}</text>
        <text className='Charts__statLabel'>Max</text>
      </view>
      <view className='Charts__stat'>
        <text className='Charts__statValue'>{stats.count}</text>
        <text className='Charts__statLabel'>Points</text>
      </view>
    </view>
  ) : null}
  {meta ? (
    <text className='Charts__metaLine'>
      {humanize(field)} · {meta.range}
      {meta.window ? ` · ${meta.aggregate ?? 'mean'} per ${meta.window}` : ''}
    </text>
  ) : null}
</view>

Step 5: ต่อปุ่ม “View readings” จากหน้า Detail

สุดท้ายเราเชื่อมหน้านี้เข้ากับ DeviceDetail จากบทที่แล้ว — เพิ่มปุ่มแล้วสลับด้วย local state เหมือนเดิม:

const [showCharts, setShowCharts] = useState(false)

if (showCharts && device) {
  return (
    <DeviceCharts
      deviceId={device.device_id}
      deviceType={device.type}
      deviceName={device.name}
      onBack={() => setShowCharts(false)}
    />
  )
}

// ...ในส่วน render เพิ่มการ์ดปุ่ม...
<Card>
  <Button label='View readings' block variant='primary'
    onTap={() => setShowCharts(true)} />
</Card>
เมื่อเห็นเส้นกราฟ temperature ค่อยๆ ไต่ขึ้นทั้งวัน:

   ╔══════════════════════════════════╗
   ║                                  ║
   ║     ┌─╱╲    ╱╲___╱   ( •̀ ω •́ )✧ ║
   ║   ──┘  ╲__╱                      ║
   ║   "เห็นแนวโน้มชัดกว่าตัวเลขเยอะ"  ║
   ║                                  ║
   ╚══════════════════════════════════╝

สรุป: บทนี้เราทำอะไรไปบ้าง

ส่วนประกอบ รายละเอียด
ดึงข้อมูล GET /api/v1/devices/:id/readings (range/field/window/aggregate/limit)
Bounded client ส่ง “เจตนา”, backend clamp ขอบเขต range/window/limit
แปลงข้อมูล toPoints: parse number, sort เวลา, ทิ้งค่าที่ใช้ไม่ได้
SVG builder buildLineChartSvg safe-by-construction (interpolate เฉพาะตัวเลข + safeColor)
render <svg content={svg} /> + viewBox scale ตามจอ
ตัวเลือก metric / time range / downsample window + สรุป min/max/last/count

ตอนนี้ app เราเห็นทั้งค่าปัจจุบัน (dashboard), สั่งงานได้ (control), และดูแนวโน้มย้อนหลัง (charts) แล้ว — เหลืออีกชิ้นเดียวคือ “การแจ้งเตือน”


Next Step

บทสุดท้ายของ phase mobile! เราจะทำหน้า Alerts — แสดง alert rule จาก backend, badge ตาม severity, read/unread, และ notification preferences พี่จะเล่าตรงๆ ว่าอะไรเป็น “ของจาก backend” และอะไรเป็น “affordance ฝั่ง client” เพราะ backend ไม่มี endpoint ประวัติ alert ที่ trigger แล้ว มาลุยกันต่อ! (^_^)v


Navigation: