LynxJS Data Visualization: กราฟ Sensor สวยๆ
LynxJS Data Visualization: กราฟ Sensor สวยๆ
Branch:
workshop/dev-13-lynxjs-chartsPhase: 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
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: