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

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

Showkhun · Workshop ·

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

Branch: workshop/dev-13-lynxjs-charts Phase: Mobile Frontend (4/5) Repo: kangana1024/iot-workshop


เฮ้! ก่อนจะเริ่ม…

น้องๆ เคยเจอสถานการณ์แบบนี้ไหม — ข้อมูล sensor ส่งมาเป็นตัวเลขเพียบเลย แต่พอเอาไปโชว์บน dashboard มันก็แค่… ตัวเลขวิ่งๆ

อุณหภูมิ: 38.4 °C
ความชื้น: 72 %
อุณหภูมิ: 38.6 °C
ความชื้น: 73 %
อุณหภูมิ: 39.1 °C
...

(ง่ะ… แล้วมันสูงขึ้นหรือเปล่านะ? ก็ไม่รู้เลย 555)

เราเชื่อว่าข้อมูลที่ดีต้องเห็นได้ด้วยตา ไม่ใช่ต้องนั่งคำนวณในหัว มันเหมือนกับการดูแผนที่ GPS กับการจำทางเองเลย — อันนึงมนุษย์ทำได้ดี อีกอันไม่ควรเสียเวลา

บทนี้เราจะเปลี่ยนตัวเลขพวกนั้นให้กลายเป็น กราฟที่มองแล้วเข้าใจในวิสาเดียว มาลุยกัน!


สิ่งที่น้องๆ จะได้จากบทนี้

  • Line Chart สำหรับ Temperature และ Humidity
  • Gauge Chart แบบ arc สวยๆ พร้อม color threshold
  • Time Range Selector (1h / 6h / 24h / 7d)
  • Multi-sensor Overlay — ดูหลาย device ใน chart เดียว
  • CSV Export ส่งออกข้อมูลออกไปได้เลย
  • Chart Animations ขั้นเทพ
  • Responsive Sizing รองรับทุกขนาดหน้าจอ

ทำไมต้องมี Data Visualization? (WHY ก่อนเลย)

ลองคิดภาพแบบนี้นะ — น้องๆ เป็น รปภ. โรงงาน มีเซ็นเซอร์อุณหภูมิ 20 ตัว ส่งค่ามาทุก 30 วินาที ถ้าต้องนั่งอ่านตัวเลข 20 ตัวทุกๆ ครึ่งนาที ชีวิตนี้จะมีเวลาทำอะไรอีก?

แต่ถ้าเห็นกราฟที่ตัวนึงพุ่งสูงผิดปกติ ตาเราจะ จับได้ทันที เพราะสมองมนุษย์ประมวลผลภาพได้เร็วกว่าตัวเลขมาก นี่คือเหตุผลที่เราต้องทำ visualization

Mermaid Diagram


Step 1: ติดตั้ง Chart Library

ก่อนวาดกราฟได้ ต้องมีสีก่อน — นี่คือ “สีและพู่กัน” ของเรา:

cd frontend-mobile

# LynxJS community charts (SVG-based)
npm install @lynx-js/charts victory-native react-native-svg

# สำหรับ CSV export
npm install react-native-share @react-native-community/clipboard

เราเลือก Victory Native เพราะมันเป็น SVG-based ทำให้กราฟคมชัดทุกขนาดหน้าจอ เหมือนกับ vector art ที่ขยายแล้วไม่แตก (ต่างจาก pixel art ที่ขยายแล้วเป็นตารางหมาก)


Step 2: TypeScript Types — ก่อนเขียนต้องรู้ shape ของข้อมูล

เหมือนก่อนจะวาดรูป ต้องรู้ว่าจะวาดอะไร เราต้องกำหนด type ก่อน:

// src/types/chart.ts

export type TimeRange = '1h' | '6h' | '24h' | '7d';

export interface ChartDataPoint {
  timestamp: Date;
  value: number;
}

export interface SeriesData {
  deviceId: string;
  deviceName: string;
  color: string;
  data: ChartDataPoint[];
}

export interface GaugeData {
  value: number;
  min: number;
  max: number;
  unit: string;
  label: string;
  thresholds: {
    warning: number;
    critical: number;
  };
}

ChartDataPoint คือจุดบนกราฟ — มีแค่ เวลา กับ ค่า แค่นั้นเลย เรียบง่ายมาก

SeriesData คือเส้นหนึ่งเส้นบนกราฟ — device นึง = เส้นนึง

GaugeData คือข้อมูลสำหรับ gauge พร้อม threshold ว่าตรงไหน warning ตรงไหน critical


Step 3: Chart Store ด้วย Zustand

ทำไมต้องมี Store แยก? เพราะ Chart Screen มีสถานะเยอะมาก — metric ที่เลือก, ช่วงเวลา, devices ที่เลือก, ข้อมูลกราฟ… ถ้าโยนไว้ใน component เดียวกัน component จะอ้วนมาก (ΦωΦ)

// src/stores/useChartStore.ts
import { create } from 'zustand';
import { immer } from 'zustand/middleware/immer';
import type { TimeRange, SeriesData, GaugeData } from '../types/chart';

type MetricType = 'temperature' | 'humidity' | 'pressure' | 'battery';

interface ChartState {
  selectedMetric: MetricType;
  timeRange: TimeRange;
  selectedDeviceIds: string[];
  series: SeriesData[];
  gauges: Record<string, GaugeData>;
  isLoading: boolean;

  setMetric: (m: MetricType) => void;
  setTimeRange: (r: TimeRange) => void;
  toggleDevice: (id: string) => void;
  setSeries: (s: SeriesData[]) => void;
  setGauge: (deviceId: string, g: GaugeData) => void;
  setLoading: (v: boolean) => void;
}

export const useChartStore = create<ChartState>()(
  immer((set) => ({
    selectedMetric: 'temperature',
    timeRange: '24h',
    selectedDeviceIds: [],
    series: [],
    gauges: {},
    isLoading: false,

    setMetric: (m) =>
      set((s) => {
        s.selectedMetric = m;
      }),

    setTimeRange: (r) =>
      set((s) => {
        s.timeRange = r;
      }),

    toggleDevice: (id) =>
      set((s) => {
        const idx = s.selectedDeviceIds.indexOf(id);
        if (idx === -1) {
          s.selectedDeviceIds.push(id);
        } else {
          s.selectedDeviceIds.splice(idx, 1);
        }
      }),

    setSeries: (series) =>
      set((s) => {
        s.series = series;
      }),

    setGauge: (deviceId, g) =>
      set((s) => {
        s.gauges[deviceId] = g;
      }),

    setLoading: (v) =>
      set((s) => {
        s.isLoading = v;
      }),
  }))
);

immer ช่วยให้เราเขียน s.selectedMetric = m แบบ mutable ได้โดยไม่ต้อง spread — อ่านง่ายขึ้นเยอะมาก


Step 4: Utility Functions — เครื่องมือช่างประจำบท

ก่อนวาดกราฟได้ ต้องแปลงข้อมูลก่อน เหมือนก่อนจะทำอาหาร ต้องหั่นผักให้ได้ขนาดที่ต้องการ:

// src/utils/chartHelpers.ts
import type { SensorReading, ChartDataPoint, TimeRange } from '../types';

// แปลง SensorReading array เป็น ChartDataPoint array
export function readingsToDataPoints(
  readings: SensorReading[],
  metric: 'temperature' | 'humidity' | 'pressure' | 'battery'
): ChartDataPoint[] {
  return readings
    .filter((r) => r[metric] !== undefined)
    .map((r) => ({
      timestamp: new Date(r.timestamp),
      value: r[metric] as number,
    }))
    .sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
}

// สร้าง CSV string จาก series data
export function seriesToCsv(series: { deviceName: string; data: ChartDataPoint[] }[]): string {
  const headers = ['timestamp', ...series.map((s) => s.deviceName)];
  const allTimestamps = [
    ...new Set(series.flatMap((s) => s.data.map((d) => d.timestamp.toISOString()))),
  ].sort();

  const rows = allTimestamps.map((ts) => {
    const row = [ts];
    for (const s of series) {
      const point = s.data.find((d) => d.timestamp.toISOString() === ts);
      row.push(point ? point.value.toString() : '');
    }
    return row.join(',');
  });

  return [headers.join(','), ...rows].join('\n');
}

// คำนวณ Y-axis domain จาก data + padding
export function calcDomain(
  data: ChartDataPoint[],
  padding = 0.1
): [number, number] {
  if (data.length === 0) return [0, 100];
  const values = data.map((d) => d.value);
  const min = Math.min(...values);
  const max = Math.max(...values);
  const range = max - min || 1;
  return [min - range * padding, max + range * padding];
}

// Series colors palette
export const SERIES_COLORS = [
  '#3B82F6', // blue
  '#10B981', // green
  '#F59E0B', // amber
  '#EF4444', // red
  '#8B5CF6', // purple
  '#06B6D4', // cyan
];

export function getSeriesColor(index: number): string {
  return SERIES_COLORS[index % SERIES_COLORS.length];
}

calcDomain น่าสนใจมาก — มันเพิ่ม padding 10% บน-ล่าง เพื่อไม่ให้เส้นกราฟชิดขอบจนดูอึดอัด เหมือนการจัดกรอบรูปให้มี margin พอดี


Step 5: Gauge Chart — มาตรวัดแบบ arc

Gauge Chart คือเหมือนมาตรวัดความเร็วในรถ บอกค่าปัจจุบันแบบ visual ทันที ไม่ต้องตีความอะไร เห็นปุ๊บรู้เลยว่าอยู่โซนไหน

     .-"""-.
    /  GREEN \
   | SAFE ZONE|    <-- อุณหภูมิปกติ
    \  (°‿°) /
     `------'

ถ้าพุ่งเข้า YELLOW = warning
ถ้าพุ่งเข้า RED = critical (ต้องแจ้งเตือนด่วน!)
// src/components/charts/GaugeChart.tsx
import { View, Text, StyleSheet } from '@lynx-js/react';
import Svg, { Circle, Path, Text as SvgText } from 'react-native-svg';
import { useTheme } from '../../theme';
import type { GaugeData } from '../../types/chart';

interface GaugeChartProps {
  data: GaugeData;
  size?: number;
}

export function GaugeChart({ data, size = 160 }: GaugeChartProps) {
  const { colors } = useTheme();
  const { value, min, max, unit, label, thresholds } = data;

  const cx = size / 2;
  const cy = size / 2;
  const radius = (size / 2) * 0.75;
  const strokeWidth = size * 0.1;

  // Arc: 220 degrees (from 200deg to 340deg หักมุมด้านล่าง)
  const startAngle = 220;
  const totalAngle = 280;

  const pct = Math.min(1, Math.max(0, (value - min) / (max - min)));
  const fillAngle = totalAngle * pct;

  const toRad = (deg: number) => (deg * Math.PI) / 180;

  const arcPath = (angle: number) => {
    const start = toRad(startAngle);
    const end = toRad(startAngle + angle);
    const x1 = cx + radius * Math.cos(start);
    const y1 = cy + radius * Math.sin(start);
    const x2 = cx + radius * Math.cos(end);
    const y2 = cy + radius * Math.sin(end);
    const large = angle > 180 ? 1 : 0;
    return `M ${x1} ${y1} A ${radius} ${radius} 0 ${large} 1 ${x2} ${y2}`;
  };

  const trackColor = colors.border;
  const fillColor =
    value >= thresholds.critical
      ? colors.danger
      : value >= thresholds.warning
      ? colors.warning
      : colors.success;

  return (
    <View style={[styles.container, { width: size }]}>
      <Svg width={size} height={size}>
        {/* Track */}
        <Path
          d={arcPath(totalAngle)}
          stroke={trackColor}
          strokeWidth={strokeWidth}
          fill="none"
          strokeLinecap="round"
        />
        {/* Fill */}
        <Path
          d={arcPath(fillAngle)}
          stroke={fillColor}
          strokeWidth={strokeWidth}
          fill="none"
          strokeLinecap="round"
        />
        {/* Value */}
        <SvgText
          x={cx}
          y={cy + 6}
          textAnchor="middle"
          fontSize={size * 0.18}
          fontWeight="700"
          fill={colors.text}
        >
          {value.toFixed(1)}{unit}
        </SvgText>
      </Svg>
      <Text style={[styles.label, { color: colors.textSecondary }]}>{label}</Text>
    </View>
  );
}

const styles = StyleSheet.create({
  container: { alignItems: 'center' },
  label: { fontSize: 12, marginTop: -8, fontWeight: '500' },
});

จุดเด่นของ component นี้คือ fillColor — มันเปลี่ยนสีอัตโนมัติตาม threshold ไม่ต้อง logic ซับซ้อนเลย ternary operator สองตัวจัดการได้หมด


Step 6: Line Chart Component — เส้นที่บอกเรื่องราวของเวลา

Line Chart เหมือนกราฟหุ้นนะ — เห็น trend ว่าขาขึ้น ขาลง หรือผันผวน นั่นแหละ ข้อมูล sensor ก็เหมือนกัน ดูแนวโน้มได้เลย:

// src/components/charts/SensorLineChart.tsx
import { View, Text, StyleSheet, Dimensions } from '@lynx-js/react';
import { useMemo } from '@lynx-js/react';
import {
  VictoryChart,
  VictoryLine,
  VictoryAxis,
  VictoryLegend,
  VictoryTooltip,
  VictoryVoronoiContainer,
} from 'victory-native';
import { useTheme } from '../../theme';
import { calcDomain } from '../../utils/chartHelpers';
import type { SeriesData } from '../../types/chart';
import { format } from 'date-fns';

interface SensorLineChartProps {
  series: SeriesData[];
  title: string;
  unit: string;
  height?: number;
  animated?: boolean;
}

const SCREEN_WIDTH = Dimensions.get('window').width;

export function SensorLineChart({
  series,
  title,
  unit,
  height = 220,
  animated = true,
}: SensorLineChartProps) {
  const { colors, isDark } = useTheme();

  const allPoints = useMemo(
    () => series.flatMap((s) => s.data),
    [series]
  );

  const domain = useMemo(() => calcDomain(allPoints), [allPoints]);

  const chartTheme = {
    axis: {
      style: {
        axis: { stroke: colors.border },
        tickLabels: { fill: colors.textSecondary, fontSize: 10 },
        grid: { stroke: colors.border, strokeDasharray: '4 4', strokeOpacity: 0.5 },
      },
    },
  };

  if (series.length === 0 || allPoints.length === 0) {
    return (
      <View style={[styles.emptyContainer, { height, backgroundColor: colors.surface }]}>
        <Text style={[styles.emptyText, { color: colors.textSecondary }]}>
          No data available
        </Text>
      </View>
    );
  }

  return (
    <View style={[styles.container, { backgroundColor: colors.surface }]}>
      <Text style={[styles.title, { color: colors.text }]}>{title}</Text>

      <VictoryChart
        width={SCREEN_WIDTH - 32}
        height={height}
        padding={{ top: 20, bottom: 40, left: 50, right: 20 }}
        containerComponent={
          <VictoryVoronoiContainer
            voronoiDimension="x"
            labels={({ datum }: { datum: { y: number } }) =>
              `${datum.y.toFixed(1)}${unit}`
            }
            labelComponent={<VictoryTooltip />}
          />
        }
        domain={{ y: domain }}
        theme={chartTheme}
      >
        {/* X Axis */}
        <VictoryAxis
          tickFormat={(t: Date) => format(t, 'HH:mm')}
          tickCount={5}
        />
        {/* Y Axis */}
        <VictoryAxis
          dependentAxis
          tickFormat={(t: number) => `${t.toFixed(0)}${unit}`}
        />

        {/* Lines */}
        {series.map((s) => (
          <VictoryLine
            key={s.deviceId}
            data={s.data.map((d) => ({ x: d.timestamp, y: d.value }))}
            style={{
              data: { stroke: s.color, strokeWidth: 2 },
            }}
            animate={animated ? { duration: 500, easing: 'cubicOut' } : false}
          />
        ))}
      </VictoryChart>

      {/* Legend */}
      {series.length > 1 && (
        <View style={styles.legend}>
          {series.map((s) => (
            <View key={s.deviceId} style={styles.legendItem}>
              <View style={[styles.legendDot, { backgroundColor: s.color }]} />
              <Text style={[styles.legendLabel, { color: colors.textSecondary }]}>
                {s.deviceName}
              </Text>
            </View>
          ))}
        </View>
      )}
    </View>
  );
}

const styles = StyleSheet.create({
  container: { borderRadius: 12, padding: 16, marginBottom: 12 },
  title: { fontSize: 14, fontWeight: '600', marginBottom: 4 },
  emptyContainer: {
    borderRadius: 12,
    justifyContent: 'center',
    alignItems: 'center',
    marginBottom: 12,
  },
  emptyText: { fontSize: 13 },
  legend: {
    flexDirection: 'row',
    flexWrap: 'wrap',
    gap: 12,
    marginTop: 4,
  },
  legendItem: { flexDirection: 'row', alignItems: 'center', gap: 6 },
  legendDot: { width: 8, height: 8, borderRadius: 4 },
  legendLabel: { fontSize: 12 },
});

VictoryVoronoiContainer คืออะไร? มันคือ “invisible zone” รอบแต่ละจุดข้อมูล เมื่อนิ้วเราเข้าใกล้จุดไหน tooltip ของจุดนั้นจะโผล่ขึ้นมา ทำให้ touch ง่ายขึ้นมากบน mobile

animate: { duration: 500, easing: 'cubicOut' } — cubicOut ทำให้กราฟวิ่งเข้าไปเร็วแล้วค่อยๆ ช้าลง เหมือนรถที่เหยียบเบรก ดูเป็นธรรมชาติกว่า linear มาก


Step 7: Time Range Selector

ทำไมต้องมี Time Range? เพราะบางครั้งเราอยากดูภาพรวม 7 วัน บางครั้งอยากซูมดูชั่วโมงล่าสุด มันเหมือนกล้องที่มี zoom ออกได้ ใช้ตามสถานการณ์:

// src/components/charts/TimeRangeSelector.tsx
import { View, TouchableOpacity, Text, StyleSheet } from '@lynx-js/react';
import { useTheme } from '../../theme';
import type { TimeRange } from '../../types/chart';

const RANGES: { label: string; value: TimeRange }[] = [
  { label: '1H', value: '1h' },
  { label: '6H', value: '6h' },
  { label: '24H', value: '24h' },
  { label: '7D', value: '7d' },
];

interface TimeRangeSelectorProps {
  value: TimeRange;
  onChange: (range: TimeRange) => void;
}

export function TimeRangeSelector({ value, onChange }: TimeRangeSelectorProps) {
  const { colors } = useTheme();

  return (
    <View style={[styles.container, { backgroundColor: colors.surface }]}>
      {RANGES.map((r) => (
        <TouchableOpacity
          key={r.value}
          style={[
            styles.tab,
            value === r.value && { backgroundColor: colors.primary },
          ]}
          onPress={() => onChange(r.value)}
        >
          <Text
            style={[
              styles.label,
              { color: value === r.value ? '#fff' : colors.textSecondary },
            ]}
          >
            {r.label}
          </Text>
        </TouchableOpacity>
      ))}
    </View>
  );
}

const styles = StyleSheet.create({
  container: {
    flexDirection: 'row',
    borderRadius: 10,
    padding: 3,
    marginBottom: 16,
  },
  tab: {
    flex: 1,
    paddingVertical: 8,
    alignItems: 'center',
    borderRadius: 8,
  },
  label: { fontSize: 13, fontWeight: '600' },
});

Component นี้ pure ที่สุดเลย — รับ value กับ onChange เป็น prop ไม่มี state ตัวเอง ทำให้ reuse ได้ทุกที่ เป็น pattern ที่เราชอบมาก


Step 8: CSV Export — เพราะข้อมูลต้องออกไปนอก app ได้

ทำไมต้อง export CSV? เพราะบางครั้ง PM อยากเอาข้อมูลไปทำ report ใน Excel, data analyst อยากเอาไปวิเคราะห์ต่อ หรือ engineer อยากเช็ค raw data ที่น่าสงสัย — app ดีต้องปล่อยข้อมูลออกได้ (⌐■_■)

// src/utils/csvExport.ts
import Share from 'react-native-share';
import { seriesToCsv } from './chartHelpers';
import type { SeriesData } from '../types/chart';

export async function exportToCsv(
  series: SeriesData[],
  metric: string,
  timeRange: string
): Promise<void> {
  const csv = seriesToCsv(series);
  const filename = `iot-${metric}-${timeRange}-${Date.now()}.csv`;

  // เขียนไฟล์ชั่วคราว
  const path = `${await getTempDir()}/${filename}`;
  await writeFile(path, csv, 'utf8');

  await Share.open({
    title: `Export ${metric} data`,
    url: `file://${path}`,
    type: 'text/csv',
    filename,
  });
}

async function getTempDir(): Promise<string> {
  // Platform-specific temp directory
  const { Platform } = await import('@lynx-js/react');
  return Platform.OS === 'ios' ? `${process.env.TMPDIR}` : '/data/local/tmp';
}

async function writeFile(path: string, content: string, encoding: string): Promise<void> {
  const { NativeModules } = await import('@lynx-js/react');
  await NativeModules.RNFSManager?.writeFile(path, content, encoding);
}

Date.now() ในชื่อไฟล์คือกันชน filename ซ้ำ — ถ้า export สองรอบจะได้สองไฟล์ไม่ทับกัน เล็กๆ น้อยๆ แต่สำคัญมาก


Step 9: Charts Screen — รวมทุกอย่างเข้าด้วยกัน

นี่คือ “ห้องควบคุม” ที่เอา component ทั้งหมดมาจัดวางให้ใช้งานได้จริง:

// src/screens/ChartsScreen.tsx
import {
  View,
  ScrollView,
  TouchableOpacity,
  Text,
  StyleSheet,
  Alert,
} from '@lynx-js/react';
import { useEffect, useCallback } from '@lynx-js/react';
import { useChartStore } from '../stores/useChartStore';
import { useDeviceStore } from '../stores/useDeviceStore';
import { api } from '../services/api';
import { SensorLineChart } from '../components/charts/SensorLineChart';
import { GaugeChart } from '../components/charts/GaugeChart';
import { TimeRangeSelector } from '../components/charts/TimeRangeSelector';
import { useTheme } from '../theme';
import {
  readingsToDataPoints,
  getSeriesColor,
} from '../utils/chartHelpers';
import { exportToCsv } from '../utils/csvExport';

const METRICS = [
  { key: 'temperature' as const, label: 'Temperature', unit: '°C' },
  { key: 'humidity' as const, label: 'Humidity', unit: '%' },
];

export default function ChartsScreen() {
  const { colors } = useTheme();
  const {
    selectedMetric,
    timeRange,
    series,
    gauges,
    isLoading,
    setMetric,
    setTimeRange,
    setSeries,
    setGauge,
    setLoading,
  } = useChartStore();

  const { devices } = useDeviceStore();

  const loadChartData = useCallback(async () => {
    setLoading(true);
    try {
      const newSeries = await Promise.all(
        devices.slice(0, 4).map(async (device, i) => {
          const readings = await api.getReadingHistory(device.id, timeRange);
          const data = readingsToDataPoints(readings, selectedMetric);

          // Set gauge for latest value
          if (readings.length > 0) {
            const latest = readings[readings.length - 1];
            const val = latest[selectedMetric] ?? 0;
            setGauge(device.id, {
              value: val,
              min: selectedMetric === 'temperature' ? -10 : 0,
              max: selectedMetric === 'temperature' ? 60 : 100,
              unit: selectedMetric === 'temperature' ? '°C' : '%',
              label: device.name,
              thresholds: {
                warning: selectedMetric === 'temperature' ? 35 : 80,
                critical: selectedMetric === 'temperature' ? 45 : 95,
              },
            });
          }

          return {
            deviceId: device.id,
            deviceName: device.name,
            color: getSeriesColor(i),
            data,
          };
        })
      );
      setSeries(newSeries);
    } catch (err) {
      console.error('Failed to load chart data:', err);
    } finally {
      setLoading(false);
    }
  }, [devices, selectedMetric, timeRange]);

  useEffect(() => {
    loadChartData();
  }, [selectedMetric, timeRange]);

  const handleExport = async () => {
    try {
      const metricInfo = METRICS.find((m) => m.key === selectedMetric);
      await exportToCsv(series, metricInfo?.label ?? selectedMetric, timeRange);
    } catch (err) {
      Alert.alert('Export Failed', 'Could not export data.');
    }
  };

  const currentMetric = METRICS.find((m) => m.key === selectedMetric)!;

  return (
    <ScrollView
      style={[styles.container, { backgroundColor: colors.background }]}
      contentContainerStyle={styles.content}
    >
      {/* Metric Tabs */}
      <View style={styles.metricTabs}>
        {METRICS.map((m) => (
          <TouchableOpacity
            key={m.key}
            style={[
              styles.metricTab,
              {
                backgroundColor:
                  selectedMetric === m.key ? colors.primary : colors.surface,
                borderColor: selectedMetric === m.key ? colors.primary : colors.border,
              },
            ]}
            onPress={() => setMetric(m.key)}
          >
            <Text
              style={[
                styles.metricLabel,
                { color: selectedMetric === m.key ? '#fff' : colors.textSecondary },
              ]}
            >
              {m.label}
            </Text>
          </TouchableOpacity>
        ))}
      </View>

      {/* Time Range */}
      <TimeRangeSelector value={timeRange} onChange={setTimeRange} />

      {/* Gauge Row */}
      <ScrollView horizontal showsHorizontalScrollIndicator={false} style={styles.gaugeRow}>
        {Object.entries(gauges).map(([deviceId, gauge]) => (
          <GaugeChart key={deviceId} data={gauge} size={140} />
        ))}
      </ScrollView>

      {/* Line Chart */}
      <SensorLineChart
        series={series}
        title={`${currentMetric.label} over ${timeRange}`}
        unit={currentMetric.unit}
        height={240}
        animated={!isLoading}
      />

      {/* Export Button */}
      <TouchableOpacity
        style={[styles.exportButton, { backgroundColor: colors.surface, borderColor: colors.border }]}
        onPress={handleExport}
        disabled={series.length === 0}
      >
        <Text style={[styles.exportLabel, { color: colors.primary }]}>
          Export CSV
        </Text>
      </TouchableOpacity>
    </ScrollView>
  );
}

const styles = StyleSheet.create({
  container: { flex: 1 },
  content: { padding: 16 },
  metricTabs: { flexDirection: 'row', gap: 8, marginBottom: 16 },
  metricTab: {
    flex: 1,
    paddingVertical: 10,
    alignItems: 'center',
    borderRadius: 10,
    borderWidth: 1,
  },
  metricLabel: { fontSize: 14, fontWeight: '600' },
  gaugeRow: { marginBottom: 16 },
  exportButton: {
    borderWidth: 1,
    borderRadius: 10,
    paddingVertical: 12,
    alignItems: 'center',
    marginTop: 8,
  },
  exportLabel: { fontSize: 15, fontWeight: '600' },
});

Promise.all บรรทัดที่ load data ให้ devices พร้อมกันทั้งหมด ไม่ใช่รอทีละตัว ถ้ามี 4 devices และแต่ละตัวใช้เวลา 500ms แบบ parallel = 500ms แบบ sequential = 2000ms — ต่างกัน 4 เท่าเลยนะ!


สรุปสิ่งที่สร้างไปในบทนี้

น้องๆ มาดูกันว่าเราสร้างอะไรไปบ้าง:

  /\_____/\
 ( o   o  )   เยี่ยม! ครบทุกอย่างเลย
  (  =^=  )
   )     (
  (_______)
  • ติดตั้ง Victory Native สำหรับ SVG chart rendering คมชัดทุกขนาด
  • สร้าง GaugeChart แบบ SVG arc พร้อม color threshold (เขียว/เหลือง/แดง)
  • สร้าง SensorLineChart พร้อม multi-series overlay, Voronoi tooltip และ animation
  • สร้าง TimeRangeSelector — pure component รับ props ไม่มี state
  • สร้าง Chart Store ด้วย Zustand + immer จัดการ state ครบ
  • เขียน utility functions: readingsToDataPoints, calcDomain, seriesToCsv
  • สร้าง CSV Export ผ่าน react-native-share แชร์ออก app ได้เลย
  • รวมทุกอย่างใน ChartsScreen พร้อม parallel data loading

Next Step

บทหน้าเราจะทำ Notifications & Alerts — ระบบแจ้งเตือนเมื่อ sensor ค่าเกิน threshold ที่ตั้งไว้ เพราะ dashboard สวยแค่ไหนก็ไม่มีประโยชน์ถ้าคนต้องมาจ้องหน้าจอตลอดเวลา ระบบต้องแจ้งเราเองได้!

Navigation: