@vietmap/tracking-sdk-react
v1.0.10
Published
React SDK for DriveConnect — drop-in Dashboard, LiveMap, and Report components with VietmapGL.
Readme
@vietmap/tracking-sdk-react
React SDK cho GPS Tracking — drop-in Dashboard, LiveMap và Report components với VietmapGL và shadcn/ui.
Cài đặt
npm install @vietmap/tracking-sdk-react
# hoặc
pnpm add @vietmap/tracking-sdk-reactImport CSS:
import "@vietmap/tracking-sdk-react/styles.css";Quick Start
import {
FleetworkProvider,
Dashboard,
LiveMap,
Report,
} from "@vietmap/tracking-sdk-react";
import "@vietmap/tracking-sdk-react/styles.css";
export default function App() {
return (
<FleetworkProvider apiKey="YOUR_API_TOKEN">
<Dashboard />
<LiveMap
apiKeyTilemap="YOUR_VIETMAP_TILE_KEY"
height="600px"
memberNameKey="userName"
/>
<Report />
</FleetworkProvider>
);
}Provider
| Prop | Type | Mặc định | Mô tả |
| ------------------------- | ------------------------------- | ------------------------------------ | -------------------------------------- |
| apiKey | string | — | API token (gửi qua header X-API-Key) |
| baseUrl | string | "https://app.fleetwork.vn/api/v1" | Base URL của API server |
| locale | "vi" \| "en" | "vi" | Ngôn ngữ giao diện |
| theme | ThemeConfig | — | Tuỳ chỉnh CSS variables (xem Theming) |
| memberNameKey | string | — | Key trong metaData của row dùng làm tên hiển thị (LiveMap + Dashboard MemberReport + Report tables) |
| onAuthError | (event) => void | — | Callback khi backend trả 401/403 |
| disableAuthErrorOverlay | boolean | false | Tắt overlay mặc định |
| renderAuthError | (event, dismiss) => ReactNode | — | Render overlay riêng |
baseUrlvàlocalelà optional — chỉ truyền khi cần override mặc định.apiKeyTilemaptruyền trực tiếp vào prop của<LiveMap>, không qua Provider.
Tên hiển thị từ metaData (memberNameKey)
Backend trả mỗi row user có field metaData chứa các thuộc tính tuỳ ý (object hoặc JSON string). Đặt memberNameKey ở Provider để tất cả LiveMap markers, Dashboard MemberReport, Report tables đồng nhất hiển thị tên từ field đó.
<FleetworkProvider apiKey="..." memberNameKey="userName">
<Dashboard /> {/* MemberReport hiện metaData.userName */}
<LiveMap apiKeyTilemap="..." /> {/* Marker name từ metaData.userName */}
<Report /> {/* Trip / Fuel tables hiện metaData.userName */}
</FleetworkProvider>Override per-component vẫn ưu tiên Provider:
<LiveMap apiKeyTilemap="..." memberNameKey="driverCode" />Fallback chain mỗi row: resolveMemberName(metaData, key) → row.name (nếu có) → row.userId. Helper export sẵn resolveMemberName cho consumer dùng ngoài (custom render, etc.).
Auth error handling (401 / 403)
SDK tự động bắt response 401/403 từ backend và hiện overlay block UI với message đúng từ backend (không tự biên text). User đóng overlay bằng nút × / Esc / click backdrop, hoặc nhấn "Tải lại trang" (chỉ hiện với 401).
Backend response được parse theo priority: message → status → detail → error → errors[0]. Ví dụ payload:
{
"error": "unauthorized",
"message": "Invalid API key",
"status": "Invalid - API Key Not Found",
"errors": ["API key not found in database"]
}sẽ hiển thị "Invalid API key" (vì message priority cao nhất — đây là field human-readable, error thường là code slug). Nếu muốn ưu tiên field khác hoặc hiện tổ hợp, dùng renderAuthError để tự render từ event.payload.
// Mặc định — overlay tự bật, vẫn nhận callback song song
<FleetworkProvider
apiKey="..."
onAuthError={(event) => {
// event: { status: 401|403, message, url, method, payload }
if (event.status === 401) {
// custom logic
}
}}
>
// Tắt overlay nếu muốn tự handle hoàn toàn
<FleetworkProvider apiKey="..." disableAuthErrorOverlay onAuthError={...}>
// Render overlay tuỳ chỉnh
<FleetworkProvider
apiKey="..."
renderAuthError={(event, dismiss) => (
<MyCustomDialog message={event.message} onClose={dismiss} />
)}
>AuthErrorEvent
| Field | Type | Mô tả |
| --------- | ------------ | --------------------------------------------------------------------------- |
| status | 401 \| 403 | HTTP status |
| message | string | Message từ backend (message/error/detail), fallback localized default |
| url | string? | URL request gây lỗi |
| method | string? | HTTP method (GET, POST, ...) |
| payload | unknown? | Raw response body từ server |
Multiple errors trong khoảng 800ms được debounce — overlay chỉ refresh chậm hơn, tránh flicker khi nhiều request fail liên tiếp.
Components
<Dashboard />
Dashboard tổng hợp với 5 widgets. Mỗi widget có thể dùng độc lập.
| Widget | Mô tả |
| ----------------- | --------------------------------------------------------- |
| SummaryCards | Tổng quãng đường / thời gian / chi phí nhiên liệu hôm nay |
| MemberReport | Bảng nhân viên với phân trang và badge trạng thái |
| ActivityHeatmap | Heatmap giờ hoạt động (T2–CN × 0–23h) |
| FuelTracking | Biểu đồ quãng đường vs tiêu thụ nhiên liệu |
| MonthlyExpenses | Biểu đồ chi phí phân loại theo tháng |
import { Dashboard, SummaryCards, MemberReport } from "@vietmap/tracking-sdk-react"
<Dashboard pollInterval={30_000} />
<Dashboard userIds={["u1", "u2"]} /> {/* lọc toàn bộ dashboard theo tập user */}
// Mọi widget cũng nhận `userIds` khi dùng độc lập:
<SummaryCards userIds={["u1", "u2"]} />
<MemberReport pageSize={20} userIds={["u1", "u2"]} />DashboardProps
| Prop | Type | Mặc định | Mô tả |
| --------------------- | --------- | -------- | -------------------- |
| date | number | hôm nay | Timestamp ms |
| pollInterval | number | 30000 | Tự động refresh (ms) |
| userIds | string[] | — | Lọc toàn bộ dashboard (mọi widget) theo tập user (API lọc server-side); bỏ trống = tất cả |
| showSummaryCards | boolean | true | Hiện/ẩn widget |
| showMemberReport | boolean | true | Hiện/ẩn widget |
| showActivityHeatmap | boolean | true | Hiện/ẩn widget |
| showFuelTracking | boolean | true | Hiện/ẩn widget |
| showMonthlyExpenses | boolean | true | Hiện/ẩn widget |
<LiveMap />
Bản đồ fleet real-time dùng VietmapGL (CDN loader) với GPU-accelerated clustering.
Tính năng:
- Tự động poll vị trí nhân viên (
pollInterval, mặc định 10 giây) - GeoJSON clustering — render 3000+ marker, zoom để mở cluster
- Marker kiểu avatar — 2 chữ cái đầu tên trong circle + label tên (pill trắng) bên dưới, tự giãn theo độ dài tên; marker đè nhau thì chữ/label của marker bị đè tự ẩn
- Marker được chọn: glow + ring mảnh cùng màu trạng thái, label luôn hiển thị
- Spiderfy — click nhiều marker trùng toạ độ sẽ fan out để chọn riêng từng người
- Sidebar nhân viên — collapsible pill, infinite scroll, sắp xếp moving → stopped → mất tín hiệu, tìm kiếm
- Tile switcher: terrain / light / dark / satellite
- Click marker → popup → Xem lộ trình → animated playback với route overlay
- History panel: thống kê quãng đường + thời gian, timeline bar, danh sách điểm theo nhóm, date picker
- Khi đang xem lịch sử, chọn nhân viên khác sẽ tự tải lại lịch sử của người đó
- Route map matching — tuyến lịch sử vẽ theo từng segment (không có đường "chim bay"), chọn nguồn qua prop
dataSource(raw/both/merged); modeboth/rawcó thêm nútRAWtrên thanh playback để chồng GPS gốc (cam nét đứt) lên tuyến đã làm mượt - Lọc theo
userIds— chỉ tải/hiển thị một tập user cụ thể (API lọc server-side) - Lọc theo status (
moving/stopped/signal_lost) — qua propstatusFilterhoặc gọi imperativeref.setStatusFilter(...) refAPI:flyTo,fitBounds,focusMember,getMembers,getMap,setStatusFilter,getStatusFilter
import { useRef } from "react"
import { LiveMap, type LiveMapRef } from "@vietmap/tracking-sdk-react"
const mapRef = useRef<LiveMapRef>(null)
<LiveMap
ref={mapRef}
apiKeyTilemap="YOUR_VIETMAP_KEY"
height="600px"
center={[106.63, 10.82]}
zoom={11}
defaultTile="terrain"
pollInterval={10_000}
maxUsers={3000}
clusterRadius={50}
clusterMaxZoom={14}
memberNameKey="userName"
showList
onMarkerClick={(m) => console.log(m.name)}
onMapReady={(map) => console.log("zoom:", map.getZoom())}
/>LiveMapProps
| Prop | Type | Mặc định | Mô tả |
| ------------------- | --------------------------- | --------------- | ------------------------------------------ |
| apiKeyTilemap | string | — | VietMap tile key (bắt buộc) |
| height | string | "100dvh" | Chiều cao container |
| center | [lng, lat] | [106.6, 10.8] | Toạ độ trung tâm bản đồ |
| zoom | number | 11 | Mức zoom ban đầu |
| defaultTile | TileType | "terrain" | Loại tile mặc định |
| pollInterval | number | 10000 | Chu kỳ refresh vị trí (ms) |
| maxUsers | number | 3000 | Số nhân viên tối đa mỗi lần poll (chỉ áp dụng khi không lọc userIds) |
| userIds | string[] | — | Chỉ tải/hiển thị các user này. API lọc server-side; bỏ trống = tất cả |
| statusFilter | MemberStatusKind[] | — | Chỉ hiện member có các status này (moving/stopped/signal_lost). Bỏ trống = tất cả. Controlled — khi set sẽ thắng setStatusFilter của ref |
| dataSource | HistoryDataSource \| null | — | Loại tuyến panel lịch sử yêu cầu (raw/both/merged); bỏ trống/null = để backend tự chọn. Xem Controllers |
| showTransitionMarkers | boolean | false | Hiện marker 🔄 ở ranh giới các segment lịch sử (hover xem time-gap). Mặc định ẩn |
| autoFit | boolean | true | Tự fit viewport vào tất cả member ở lần tải đầu. Chỉ fit 1 lần và dừng ngay khi người dùng pan/zoom → poll không đè view. false để tắt hẳn, giữ center/zoom |
| clusterRadius | number | 50 | Bán kính cluster (px) |
| clusterMaxZoom | number | 14 | Mức zoom tắt cluster |
| memberNameKey | string | — | Key trong metadata dùng làm tên hiển thị |
| members | MemberStatus[] | — | Override data (tắt API polling) |
| showList | boolean | true | Hiện sidebar nhân viên |
| onMarkerClick | (m) => void \| boolean | — | Return false để chặn popup mặc định |
| onMemberClick | (m) => void \| boolean | — | Click item trên sidebar |
| onMapClick | ([lng, lat]) => void | — | Click nền bản đồ |
| onMapReady | (map) => void | — | Fires sau khi bản đồ load xong |
| renderMarkerPopup | (m) => ReactNode | — | Tuỳ chỉnh nội dung popup |
| renderMemberItem | (m, default) => ReactNode | — | Tuỳ chỉnh hàng sidebar |
LiveMapRef (imperative API)
mapRef.current?.flyTo([106.63, 10.82], 14);
mapRef.current?.fitBounds([
[102, 8],
[110, 23],
]);
mapRef.current?.focusMember("user-123"); // Bay đến + mở popup
mapRef.current?.getMembers(); // MemberStatus[] (đã áp status filter)
mapRef.current?.getMap(); // MapInstance
mapRef.current?.setStatusFilter(["moving"]); // Lọc; [] hoặc undefined = xoá lọc
mapRef.current?.getStatusFilter(); // MemberStatusKind[] | undefinedLọc theo nhóm user (userIds)
Mặc định LiveMap tải toàn bộ fleet. Truyền userIds để chỉ tải/hiển thị một tập user — API lọc server-side nên không kéo về thừa data:
// Chỉ hiện 3 user này; bỏ trống/`undefined` -> hiện tất cả như cũ
<LiveMap apiKeyTilemap="..." userIds={["driver-01", "driver-02", "driver-03"]} />Lọc theo status
Chỉ hiện member theo trạng thái. Filter áp lên cả marker lẫn sidebar và getMembers(). Hai cách dùng:
// 1) Controlled — qua prop (component cha giữ state)
<LiveMap apiKeyTilemap="..." statusFilter={["moving", "stopped"]} />
// 2) Imperative — component tự gọi filter qua ref (bỏ prop để ở chế độ uncontrolled)
const mapRef = useRef<LiveMapRef>(null)
<LiveMap ref={mapRef} apiKeyTilemap="..." />
mapRef.current?.setStatusFilter(["signal_lost"]) // chỉ hiện mất tín hiệu
mapRef.current?.setStatusFilter([]) // xoá lọcKhi truyền prop
statusFilter, nó thắngsetStatusFiltercủa ref (controlled). Bỏ prop thì ref điều khiển (uncontrolled). Status hợp lệ:"moving","stopped","signal_lost".
<Report />
Hub báo cáo — màn hình chính với 3 thẻ, điều hướng vào từng loại.
| Báo cáo | Mô tả | | ------------- | ------------------------------------------------------------------------ | | Hành trình | Bảng tổng hợp per-user → click 1 user mở chi tiết hành trình của user đó | | Nhiên liệu | Bảng tổng hợp per-user → click 1 user mở chi tiết nhiên liệu của user đó | | Giờ hoạt động | Số nhân viên hoạt động theo giờ |
Drill-down theo user: Hành trình & Nhiên liệu hiển thị bảng Tổng hợp; cột cuối "Thao tác" có nút Xem chi tiết (👁, click cả dòng cũng được) → mở trang chi tiết riêng của user đó (phân trang/sort/đổi khoảng ngày + nút quay lại, ẩn cột nhân viên). (Trước đây dùng 2 tab Tổng hợp/Chi tiết — đã thay bằng drill-down.)
Tất cả bảng: header có thể sort, sticky header + sticky hàng tổng, scroll ngang/dọc, DateRangePicker 2 tháng với nút xác nhận.
<Report from={Date.now() - 30 * 86_400_000} to={Date.now()} />
// userIds: chỉ lấy một tập user cho mọi báo cáo (API lọc server-side)
<Report from={from} to={to} userIds={["u1", "u2"]} />ReportProps: from?, to?, userIds?, className?, style?, onError?.
Dùng từng sub-report độc lập:
import {
TripSummaryReport,
TripDetailReport,
FuelSummaryReport,
FuelDetailReport,
ActivityTimeReport,
} from "@vietmap/tracking-sdk-react";
// Summary: range, onRangeChange, onBack, onError, pageSize, userIds, onUserClick
<TripSummaryReport
range={{ from, to }}
onRangeChange={setRange}
onUserClick={(userId, name) => openDetail(userId, name)}
/>;
// Detail: thêm userId + userName để drill-down 1 user
// (ẩn cột nhân viên, lọc theo userId, tên hiện ở tiêu đề)
<TripDetailReport
range={{ from, to }}
onRangeChange={setRange}
userId="u1"
userName="Nguyễn Văn A"
/>;Hooks
Tất cả hooks cần FleetworkProvider trong tree. Trả về { data, isLoading, isFetching, error, refetch }.
Không dùng TanStack Query — hooks thuần
useState/useEffect.
isLoading— chỉtrueở lần tải đầu (chưa có data) → hiện skeleton. Có delay 150ms để tránh nháy với response nhanh.isFetching—truemỗi khi có request đang chạy, kể cả refetch nền (đổi trang/sort/filter/khoảng ngày) khi data cũ vẫn hiển thị. Dùng cho spinner/thanh loading.- Các report giữ data cũ khi refetch nhưng vẫn hiện skeleton lại giống lần tải đầu mỗi khi đổi trang/sort/filter (để rõ là đang tải lại). Các hook polling (map/dashboard) thì không, tránh nháy skeleton mỗi nhịp poll.
// Dashboard
const { data } = useSummaryCards({ date?, userIds?, pollInterval? })
const { data } = useMemberReport({ date?, page?, pageSize?, status?, userIds? })
const { data } = useActivityHeatmap({ from?, to?, metric?, userId?, userIds? })
const { data } = useFuelTracking({ from?, to?, groupBy?, userId?, userIds? })
const { data } = useMonthlyExpenses({ from?, to?, currency?, userIds? })
// LiveMap
const { data } = useMembers({ pollInterval?, nameKey?, maxUsers?, userIds? })
const { data } = useMember(userId)
const { data } = useHistoryRoute({ userId, startTime, endTime })
// Report
// userId? (1 user, drill-down) hoặc userIds? (nhiều user, summary lọc server-side)
const { data } = useTripSummaryReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId?, userIds? })
const { data } = useTripDetailReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId? })
const { data } = useFuelSummaryReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId?, userIds? })
const { data } = useFuelDetailReport({ from, to, page?, pageSize?, sortBy?, sortDesc?, userId? })
const { data } = useActivityTimeReport({ from, to, page?, pageSize? })Controllers (framework-agnostic)
Dùng ngoài React — Zustand, Redux, Node.js scripts, hoặc bất kỳ context nào.
import {
initFleetwork,
DashboardController,
LiveMapController,
ReportController,
} from "@vietmap/tracking-sdk-react";
initFleetwork({ apiKey: "...", baseUrl: "..." });
const summary = await DashboardController.getSummaryCards();
const members = await LiveMapController.getMembers({ pageSize: 3000 });
const some = await LiveMapController.getMembers({ userIds: ["u1", "u2"] }); // lọc server-side
const report = await DashboardController.getMemberReport(Date.now(), { userIds: ["u1", "u2"] });
const history = await LiveMapController.getHistoryRoute(userId, from, to);
const trips = await ReportController.getTripSummary({ from, to, userIds: ["u1", "u2"] });
const fuel = await ReportController.getFuelSummary({ from, to });
const userTrips = await ReportController.getTripDetail({ from, to, userId: "u1" }); // chi tiết 1 userPOST +
userIds: Toàn bộDashboardController.*vàLiveMapController.getMembers,ReportController.getTripSummary/getFuelSummarygọi bản POST (body) của API. TruyềnuserIds(mảng) để backend lọc nhiều user server-side; vượt 1000 id sẽ tự tách nhiều call rồi gộp. Báo cáo chi tiết (getTripDetail/getFuelDetail),getActivityTime, history/latest vẫn GET; chi tiết lọc 1 user quauserId.
HttpService — gọi endpoint tuỳ chỉnh
Dùng client đã có sẵn API key + error handling (401/403 tự phát auth event) để gọi endpoint riêng:
import { HttpService } from "@vietmap/tracking-sdk-react";
const data = await HttpService.get<MyType>("my/endpoint", { from, to });
const created = await HttpService.post<MyType>("my/endpoint", { name: "..." });Cả 4 method get/post/put/delete trả thẳng data; lỗi đã được interceptor normalize thành SdkError (message dễ đọc + status). Tham số cuối (optional) nhận AxiosInstance riêng nếu không muốn dùng global client.
Lịch sử hành trình — dataSource
getHistoryComparison() và getHistoryRoute() nhận option dataSource để chọn tuyến mà backend dựng.
| dataSource | Backend làm gì | points lấy từ | Field đi kèm |
| --- | --- | --- | --- |
| bỏ trống / null | Mặc định. Không gửi DataSource. Ưu tiên enriched; khoảng thời gian nào chưa map-matching thì tự fallback sang raw | enrichedSegments (flatten), fallback trackingData | enrichedSegments (khi có) |
| "raw" | Bỏ qua map-matching hoàn toàn (không query enriched) | trackingData (GPS gốc) | rawPoints |
| "both" | Query cả hai nguồn, trả song song để client tự đối chiếu | enrichedSegments (flatten) | rawPoints + enrichedPoints + enrichedSegments |
| "merged" | Lấy enriched làm xương sống, chèn raw vào các đoạn matcher không match được | trackingData (tuyến đã ghép) | routeSummary |
import { LiveMapController } from "@vietmap/tracking-sdk-react";
// Mặc định — backend tự quyết enriched hay raw
const auto = await LiveMapController.getHistoryComparison(userId, from, to);
auto.dataSource; // null
// GPS gốc, không map-matching — nhẹ nhất
const raw = await LiveMapController.getHistoryComparison(userId, from, to, {
dataSource: "raw",
});
// Đối chiếu: vẽ enriched liền nét, raw nét đứt chồng lên
const cmp = await LiveMapController.getHistoryComparison(userId, from, to, {
dataSource: "both",
});
cmp.enrichedSegments?.forEach((seg) => drawPolyline(seg)); // vẽ từng segment riêng
if (cmp.rawPoints) drawDashed(cmp.rawPoints);
// Một tuyến liền mạch, không bị hở ở chỗ matcher fail
const merged = await LiveMapController.getHistoryComparison(userId, from, to, {
dataSource: "merged",
});
drawPolyline(merged.points);
// getHistoryRoute() = getHistoryComparison().points
const points = await LiveMapController.getHistoryRoute(userId, from, to, {
dataSource: "merged",
});Kết quả trả về luôn có field dataSource echo lại mode đã dùng (null khi để backend tự chọn), nên khi truyền mode động bạn không cần tự theo dõi.
Vì sao points lấy từ nguồn khác nhau: với "both" và mode mặc định, enrichedSegments là tuyến chuẩn và mỗi segment phải vẽ thành một polyline riêng — nối chúng lại sẽ sinh đường chim bay ở chỗ matcher cắt. Với "merged" thì ngược lại: trackingData đã là tuyến ghép hoàn chỉnh, nếu lại flatten enrichedSegments sẽ mất đúng những điểm raw vừa chèn vào và hở lại như cũ.
rawPointsđược chuẩn hoá: ở mode"raw"backend trả GPS gốc trongtrackingDatavà bỏ trốngrawData, nhưng SDK vẫn setrawPointsđể bạn đọc tuyến raw ở cùng một field bất kể mode.
Chọn mode từ component <LiveMap />
Prop dataSource được truyền thẳng xuống panel lịch sử; đổi prop sẽ tự fetch lại tuyến, giữ nguyên ngày đang chọn.
<LiveMap apiKeyTilemap={key} dataSource="merged" />Chỉ
"both"và"raw"trả về tuyến raw, nên nút bật/tắt overlay raw nét đứt trong LiveMap sẽ tự ẩn ở mode"merged"và ở mode mặc định khi backend dựng được enriched.
Xem src/example/pages/PageLiveMap.tsx để có ví dụ cho người dùng bấm chọn 4 mode ngay trên UI.
Segment: gộp trùng index & vẽ theo segment
enrichedSegments trả về có thể chứa nhiều entry cùng một segmentIndex (backend cắt một segment logic thành nhiều mảnh). SDK gộp chúng lại trước khi vẽ bằng helper thuần được export:
import { mergeSegmentsByIndex } from "@vietmap/tracking-sdk-react";
// EnrichedSegment[] -> EnrichedSegment[] (mỗi segmentIndex = 1 polyline)
const merged = mergeSegmentsByIndex(res.enrichedSegments);Gộp theo index, nối điểm theo thứ tự mảng (không sort theo thời gian, tránh zigzag). Các segment sau gộp được vẽ thành các polyline riêng (cắt tại ranh giới nên không có đường "chim bay") nhưng cùng một màu.
Marker chuyển tiếp 🔄 giữa hai segment (hover hiện time-gap — khoảng trống thời gian đó là lý do matcher cắt đoạn) mặc định ẩn; bật bằng prop showTransitionMarkers trên <LiveMap>:
<LiveMap apiKeyTilemap="..." showTransitionMarkers />Theming
Design tokens của SDK là bộ shadcn slate (oklch), hỗ trợ sẵn light + dark.
Dark mode
Class-based: app chỉ cần toggle class .dark trên <html> là toàn bộ SDK components (bảng, chart, popup, overlay) tự đổi theo — không cần cấu hình gì thêm:
document.documentElement.classList.toggle("dark");Tuỳ chỉnh qua prop theme
Tuỳ chỉnh CSS variables qua prop theme của FleetworkProvider:
<FleetworkProvider
theme={{
colors: {
primary: "#2563eb",
background: "#0f172a",
text: "#f1f5f9",
border: "#1e293b",
destructive: "#ef4444",
statusMoving: "#10b981",
statusStopped: "#f59e0b",
statusSignalLost: "#94a3b8",
},
borderRadius: 8,
fontFamily: "Inter, sans-serif",
}}
>ThemeConfig
| Field | CSS variable | Mô tả |
| ------------------------- | ---------------------- | ------------------------------ |
| colors.primary | --primary | Màu chính (button, focus ring) |
| colors.background | --background | Màu nền trang |
| colors.text | --foreground | Màu chữ chính |
| colors.border | --border | Màu viền |
| colors.destructive | --destructive | Màu lỗi / xoá |
| colors.statusMoving | --status-moving | Màu chấm "đang di chuyển" |
| colors.statusStopped | --status-stopped | Màu chấm "dừng" |
| colors.statusSignalLost | --status-signal-lost | Màu chấm "mất tín hiệu" |
| borderRadius | --radius | Border radius (px) |
| fontFamily | --dc-font | Font chữ |
License
MIT © VietMap
