UNPKG

fleeta-components

Version:

A comprehensive React component library for fleet management applications

1,414 lines β€’ 59.8 kB
import { clsx } from "clsx"; import { twMerge } from "tailwind-merge"; import { create } from "zustand"; import { jsxs, jsx } from "react/jsx-runtime"; import React, { useRef, useState, useEffect } from "react"; import { Car, AlertTriangle, Layers, MapPin, Pause, Play, SkipBack, SkipForward, VolumeX, Volume2, Download, Map as Map$2, Maximize2, Loader2 } from "lucide-react"; import { Map as Map$1, Marker, NavigationControl, Source, Layer } from "react-map-gl"; function cn(...inputs) { return twMerge(clsx(inputs)); } function VehicleMarker({ icon = /* @__PURE__ */ jsx(Car, { size: 8 }), speed, course, speedUnit = "km/h", className }) { const displaySpeed = speed !== null && speed !== void 0 ? Math.round(speed) : null; return /* @__PURE__ */ jsxs("div", { className: cn("relative flex flex-col items-center", className), style: { marginTop: "-10px" }, children: [ /* @__PURE__ */ jsx( "div", { className: "relative z-10 w-5 h-5 bg-primary rounded-full flex items-center justify-center text-white shadow-lg transition-transform border-0 border-white dark:border-gray-800", children: icon } ), /* @__PURE__ */ jsx("div", { className: "absolute top-full left-1/2 -translate-x-1/2 mt-1 bg-black/30 text-white px-2 py-0.5 rounded text-xs flex items-center gap-1 whitespace-nowrap min-w-[60px] justify-center", children: /* @__PURE__ */ jsxs("div", { className: "absolute top-full left-1/2 -translate-x-1/2 mt-1 bg-black/30 dark:bg-gray-900/50 text-white dark:text-gray-100 px-2 py-0.5 rounded text-xs flex items-center gap-1 whitespace-nowrap min-w-[60px] justify-center", children: [ /* @__PURE__ */ jsx("span", { className: "flex-shrink-0", children: displaySpeed !== null ? displaySpeed : "--" }), " ", speedUnit, course !== null && /* @__PURE__ */ jsxs("span", { className: "ml-2 border-l border-white/20 dark:border-gray-400/30 pl-2", title: "Vehicle heading", children: [ Math.round(course ?? 0), "Β°" ] }) ] }) }) ] }); } const MAP_STYLES = { streets: "mapbox://styles/mapbox/streets-v12", satellite: "mapbox://styles/mapbox/satellite-v9", outdoors: "mapbox://styles/mapbox/outdoors-v12", light: "mapbox://styles/mapbox/light-v11", dark: "mapbox://styles/mapbox/dark-v11" }; const useMapStyleStore = create((set) => ({ style: "streets", // Default to streets style setStyle: (style) => set({ style }) })); function EventComponent({ sensorData, currentTime, videoDuration = 60, className }) { const visiblePoints = React.useMemo(() => { if (!sensorData || !Array.isArray(sensorData)) { return []; } const filtered = sensorData.filter( (point) => point.timestamp <= (currentTime ?? 0) && point.x !== null && point.y !== null && point.z !== null ); return filtered; }, [sensorData, currentTime]); React.useMemo(() => { if (!visiblePoints.length) return null; return visiblePoints[visiblePoints.length - 1]; }, [visiblePoints]); const maxValue = React.useMemo(() => { if (!visiblePoints.length) return 1; const maxX = Math.max(...visiblePoints.map((p) => Math.abs(p.x || 0))); const maxY = Math.max(...visiblePoints.map((p) => Math.abs(p.y || 0))); const maxZ = Math.max(...visiblePoints.map((p) => Math.abs(p.z || 0))); const normalizedMax = Math.max(maxX, maxY, maxZ, 1) / 127.5; return normalizedMax; }, [visiblePoints]); const toGForce = (value) => { if (value === null) return 0; return value / 127.5; }; const getHeightPercentage = (value) => { if (value === null) { return 50; } const gForce = toGForce(value); const percentage = 50 - gForce / maxValue * 40; const clampedPercentage = Math.max(10, Math.min(90, percentage)); return clampedPercentage; }; const generateLines = () => { if (!visiblePoints.length) return []; const xPosX = 25; const xPosY = 50; const xPosZ = 75; const xLines = []; const yLines = []; const zLines = []; visiblePoints.forEach((point, index) => { point.timestamp / videoDuration; const yPosX = getHeightPercentage(point.x); const yPosY = getHeightPercentage(point.y); const yPosZ = getHeightPercentage(point.z); xLines.push( /* @__PURE__ */ jsx( "line", { x1: xPosX, y1: "50%", x2: xPosX, y2: `${yPosX}%`, stroke: "#8b5cf6", strokeWidth: "10", "data-timestamp": point.timestamp.toFixed(2) }, `x-${index}` ) ); yLines.push( /* @__PURE__ */ jsx( "line", { x1: xPosY, y1: "50%", x2: xPosY, y2: `${yPosY}%`, stroke: "#10b981", strokeWidth: "10", "data-timestamp": point.timestamp.toFixed(2) }, `y-${index}` ) ); zLines.push( /* @__PURE__ */ jsx( "line", { x1: xPosZ, y1: "50%", x2: xPosZ, y2: `${yPosZ}%`, stroke: "#3b82f6", strokeWidth: "10", "data-timestamp": point.timestamp.toFixed(2) }, `z-${index}` ) ); }); const result = []; if (xLines.length > 0) result.push(xLines[xLines.length - 1]); if (yLines.length > 0) result.push(yLines[yLines.length - 1]); if (zLines.length > 0) result.push(zLines[zLines.length - 1]); return result; }; return /* @__PURE__ */ jsxs("div", { className: cn("relative bg-blue-900/50 rounded-xl", className), style: { width: "100%", height: "100%" }, children: [ /* @__PURE__ */ jsxs( "svg", { width: "100%", height: "100%", viewBox: "0 0 100 100", preserveAspectRatio: "none", className: "overflow-visible", children: [ /* @__PURE__ */ jsx( "line", { x1: "0", y1: "50%", x2: "100%", y2: "50%", stroke: "#4b5563", strokeWidth: "2" } ), generateLines() ] } ), (!sensorData || !Array.isArray(sensorData) || !visiblePoints.length) && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 flex items-center justify-center z-20", children: /* @__PURE__ */ jsx("div", { className: "text-white/50 text-xs" }) }) ] }); } const __vite_import_meta_env__ = {}; function MapComponent({ height = "200px", gpsPoints = [], currentTime, sensorData, showEventComponent = true, videoDuration = 60, className, speedUnit = "km/h", mapStyle: propMapStyle }) { var _a, _b, _c; const mapRef = useRef(null); const containerRef = useRef(null); const mapInstanceId = useRef(Math.random().toString(36).substr(2, 9)); const [isMapReady, setIsMapReady] = useState(false); const [viewState, setViewState] = useState({ longitude: -122.4194, latitude: 37.7749, zoom: 13, pitch: 0, bearing: 0 }); const [isDarkMode, setIsDarkMode] = useState(false); const [userSelectedStyle, setUserSelectedStyle] = useState(null); const { style: storeMapStyleKey } = useMapStyleStore(); const effectiveMapStyleKey = React.useMemo(() => { if (userSelectedStyle) { return userSelectedStyle; } if (propMapStyle) { return propMapStyle; } return isDarkMode ? "dark" : "light"; }, [userSelectedStyle, propMapStyle, storeMapStyleKey, isDarkMode]); const mapStyle = MAP_STYLES[effectiveMapStyleKey]; useEffect(() => { const checkDarkMode = () => { const hasClassDarkMode = document.documentElement.classList.contains("dark"); const systemPrefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches; const isDark = hasClassDarkMode || !document.documentElement.classList.contains("light") && systemPrefersDark; setIsDarkMode(isDark); }; checkDarkMode(); const observer = new MutationObserver(checkDarkMode); observer.observe(document.documentElement, { attributes: true, attributeFilter: ["class"] }); const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); const handleMediaChange = () => checkDarkMode(); if (mediaQuery.addEventListener) { mediaQuery.addEventListener("change", handleMediaChange); } else { mediaQuery.addListener(handleMediaChange); } return () => { observer.disconnect(); if (mediaQuery.removeEventListener) { mediaQuery.removeEventListener("change", handleMediaChange); } else { mediaQuery.removeListener(handleMediaChange); } }; }, []); const mapboxToken = (__vite_import_meta_env__ == null ? void 0 : __vite_import_meta_env__.VITE_MAPBOX_ACCESS_TOKEN) || ""; useEffect(() => { if (containerRef.current) { setIsMapReady(true); } return void 0; }, []); useEffect(() => { if (isMapReady && mapRef.current) { const canvas = mapRef.current.getCanvas(); if (canvas) { canvas.style.borderRadius = "0.375rem"; } } return void 0; }, [isMapReady, mapRef.current]); useEffect(() => { if (mapRef.current && isMapReady) { setTimeout(() => { if (mapRef.current) { mapRef.current.resize(); } }, 300); const resizeObserver = new ResizeObserver(() => { if (mapRef.current) { setTimeout(() => { mapRef.current.resize(); }, 50); } }); if (containerRef.current) { resizeObserver.observe(containerRef.current); const parentContainer = containerRef.current.parentElement; if (parentContainer) { resizeObserver.observe(parentContainer); } } const resizeDelays = [100, 300, 500, 800]; resizeDelays.forEach((delay) => { setTimeout(() => { if (mapRef.current) { mapRef.current.resize(); } }, delay); }); return () => { resizeObserver.disconnect(); }; } return void 0; }, [isMapReady]); const validGpsPoints = React.useMemo(() => { if (!(gpsPoints == null ? void 0 : gpsPoints.length)) return []; return gpsPoints.filter((point) => { const lat = Number(point.lat); const lng = Number(point.lng); return point.lat != null && point.lng != null && !isNaN(lat) && !isNaN(lng) && isFinite(lat) && isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180; }); }, [gpsPoints]); const hasValidGpsData = validGpsPoints.length > 0; useEffect(() => { if (validGpsPoints.length > 0 && validGpsPoints[0].lat !== null && validGpsPoints[0].lng !== null) { setViewState((prev) => ({ ...prev, longitude: validGpsPoints[0].lng, latitude: validGpsPoints[0].lat })); } }, [validGpsPoints]); const visibleGpsPoints = React.useMemo(() => { if (!(validGpsPoints == null ? void 0 : validGpsPoints.length) || currentTime === void 0) return []; const currentTimeSeconds = currentTime; return validGpsPoints.filter((point) => point.relativeTime <= currentTimeSeconds); }, [validGpsPoints, currentTime]); const currentPosition = React.useMemo(() => { if (!(validGpsPoints == null ? void 0 : validGpsPoints.length) || currentTime === void 0) return null; const currentTimeSeconds = currentTime; const position = validGpsPoints.find((point, index) => { const nextPoint = validGpsPoints[index + 1]; if (!nextPoint) return true; const pointRelativeTime = point.relativeTime; const nextRelativeTime = nextPoint.relativeTime; return currentTimeSeconds >= pointRelativeTime && currentTimeSeconds < nextRelativeTime; }); return position; }, [validGpsPoints, currentTime]); useEffect(() => { if (currentPosition && mapRef.current) { setViewState((prev) => ({ ...prev, longitude: Number(currentPosition.lng), latitude: Number(currentPosition.lat), zoom: prev.zoom })); } }, [currentPosition]); const handleStyleChange = (styleKey) => { if (styleKey === "auto") { setUserSelectedStyle(null); } else { setUserSelectedStyle(styleKey); } }; const mapStyleOptions = [ { key: "auto", label: "Auto", icon: "πŸŒ“" }, { key: "light", label: "Light", icon: "β˜€οΈ" }, { key: "dark", label: "Dark", icon: "πŸŒ™" }, { key: "streets", label: "Streets", icon: "πŸ—ΊοΈ" }, { key: "satellite", label: "Satellite", icon: "πŸ›°οΈ" }, { key: "outdoors", label: "Outdoors", icon: "πŸ”οΈ" } ]; if (!mapboxToken) { return /* @__PURE__ */ jsx("div", { className: cn("flex items-center justify-center h-full", className), style: { height }, children: /* @__PURE__ */ jsxs("div", { className: "text-center", children: [ /* @__PURE__ */ jsx(AlertTriangle, { className: "w-8 h-8 text-amber-500 mx-auto mb-2" }), /* @__PURE__ */ jsx("p", { className: "text-sm text-gray-600 dark:text-gray-400", children: "Mapbox access token is missing" }) ] }) }); } return /* @__PURE__ */ jsxs("div", { ref: containerRef, className: cn("relative w-full p-1", className), style: { height }, children: [ /* @__PURE__ */ jsxs("div", { className: "flex items-center justify-between h-[30px]", children: [ /* @__PURE__ */ jsxs("h4", { className: "text-xs font-medium text-gray-300 dark:text-gray-300 flex items-center flex-1", children: [ "GPS Navigation", currentPosition && /* @__PURE__ */ jsxs("span", { className: "ml-2 text-xs font-normal text-gray-300 dark:text-gray-300 flex items-center gap-1", children: [ (_a = currentPosition.lat) == null ? void 0 : _a.toFixed(2), "Β°, ", (_b = currentPosition.lng) == null ? void 0 : _b.toFixed(2), "Β°", currentPosition.speedKmh !== null && ` β€’ ${Math.round(speedUnit === "mph" ? currentPosition.speedKmh * 0.621371 : currentPosition.speedKmh)} ${speedUnit}`, currentPosition.course !== null && ` β€’ ${Math.round(currentPosition.course)}Β° heading` ] }) ] }), /* @__PURE__ */ jsx("div", { className: "flex items-center gap-1 mx-2", children: /* @__PURE__ */ jsxs("div", { className: "relative group", children: [ /* @__PURE__ */ jsxs("button", { className: "flex items-center gap-1 px-2 py-1 text-xs bg-black/20 hover:bg-black/30 text-gray-300 rounded transition-colors", children: [ /* @__PURE__ */ jsx(Layers, { className: "w-3 h-3" }), /* @__PURE__ */ jsx("span", { className: "hidden sm:inline", children: userSelectedStyle ? (_c = mapStyleOptions.find((opt) => opt.key === userSelectedStyle)) == null ? void 0 : _c.label : "Auto" }) ] }), /* @__PURE__ */ jsx("div", { className: "absolute top-full right-0 mt-1 bg-gray-800 border border-gray-600 rounded shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all duration-200 z-50 min-w-[120px]", children: mapStyleOptions.map((option) => /* @__PURE__ */ jsxs( "button", { onClick: () => handleStyleChange(option.key), className: cn( "w-full text-left px-3 py-2 text-xs hover:bg-gray-700 text-gray-300 flex items-center gap-2 transition-colors", option.key === "auto" && !userSelectedStyle || option.key === userSelectedStyle ? "bg-gray-700" : "" ), children: [ /* @__PURE__ */ jsx("span", { children: option.icon }), /* @__PURE__ */ jsx("span", { children: option.label }) ] }, option.key )) }) ] }) }), showEventComponent && sensorData && /* @__PURE__ */ jsx("div", { className: "flex-shrink-0 h-[25px] w-[55px] flex items-center justify-center ml-auto pr-2", children: /* @__PURE__ */ jsx( EventComponent, { sensorData, currentTime, videoDuration: videoDuration || 60, className: "h-[25px] rounded" } ) }) ] }), !hasValidGpsData && /* @__PURE__ */ jsx("div", { className: "absolute inset-0 flex items-center justify-center z-20 pointer-events-none", children: /* @__PURE__ */ jsx("div", { className: "bg-black/50 px-6 py-3 rounded-lg text-white text-sm", children: "No GPS data found in this recording" }) }), /* @__PURE__ */ jsx( "div", { className: "absolute inset-0 top-[40px] bottom-0", style: { transition: "width 0.3s ease, height 0.3s ease", width: "100%", height: "calc(100% - 40px)" }, children: isMapReady && // Map container - ensures MapGL fills the entire available space /* @__PURE__ */ jsx( "div", { style: { width: "100%", height: "100%", position: "relative", overflow: "hidden" }, className: "map-container", children: /* @__PURE__ */ jsxs( Map$1, { ref: mapRef, ...viewState, onMove: (evt) => setViewState(evt.viewState), mapStyle, mapboxAccessToken: mapboxToken, style: { width: "100%", height: "100%" }, children: [ currentPosition && currentPosition.lat != null && currentPosition.lng != null && /* @__PURE__ */ jsx(Marker, { longitude: currentPosition.lng, latitude: currentPosition.lat, anchor: "top", children: /* @__PURE__ */ jsx( VehicleMarker, { icon: /* @__PURE__ */ jsx(Car, { size: 16 }), speed: (currentPosition == null ? void 0 : currentPosition.speedKmh) !== null ? speedUnit === "mph" ? currentPosition.speedKmh * 0.621371 : currentPosition.speedKmh : void 0, course: currentPosition == null ? void 0 : currentPosition.course, speedUnit } ) }), validGpsPoints.length === 0 && /* @__PURE__ */ jsx(Marker, { longitude: viewState.longitude, latitude: viewState.latitude, anchor: "center", children: /* @__PURE__ */ jsx("div", { className: "w-8 h-8 bg-gray-500/50 rounded-full flex items-center justify-center text-white animate-pulse", children: /* @__PURE__ */ jsx(MapPin, { size: 16 }) }) }), /* @__PURE__ */ jsx(NavigationControl, { position: "top-right", showCompass: false }), visibleGpsPoints.length > 1 && /* @__PURE__ */ jsx( Source, { id: "device-route", type: "geojson", data: { type: "Feature", properties: {}, geometry: { type: "LineString", coordinates: visibleGpsPoints.map((p) => [p.lng, p.lat]) } }, children: /* @__PURE__ */ jsx( Layer, { id: "route-line", type: "line", paint: { "line-color": "#61007D", "line-width": 3, "line-opacity": 0.8 } } ) } ) ] }, `map-${mapInstanceId.current}` ) } ) } ) ] }); } function formatTime(seconds) { const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`; } function getEffectiveData(enableMetadataParsing, autoParsedData, externalData) { return enableMetadataParsing ? autoParsedData || [] : externalData || []; } const videoStateUtils = { /** * λ‘œλ”© μƒνƒœ 확인 */ isLoading(externalLoading, videoLoading) { return externalLoading || videoLoading; }, /** * λΉ„λ””μ˜€ 쑴재 확인 */ hasVideo(videoUrl, hasError) { return Boolean(videoUrl && !hasError); }, /** * 지도 ν‘œμ‹œ μ—¬λΆ€ 확인 */ shouldShowMap(showMap, hasVideo) { return showMap && hasVideo; }, /** * 이벀트 μ»΄ν¬λ„ŒνŠΈ ν‘œμ‹œ μ—¬λΆ€ 확인 */ shouldShowEventComponent(showEventComponent, hasVideo) { return showEventComponent && hasVideo; } }; function generateTransformStyle(isHorizontalFlipped, isVerticalFlipped) { const transforms = []; if (isHorizontalFlipped) { transforms.push("scaleX(-1)"); } if (isVerticalFlipped) { transforms.push("scaleY(-1)"); } return transforms.join(" "); } function calculateVideoContainerHeight(shouldShowMap) { return shouldShowMap ? "h-[calc(100%-200px)]" : "h-full"; } function VideoControls({ videoState, handlers, showControls, hasVideo, isLoading, onDownload, showMap = false }) { if (!showControls || !hasVideo || isLoading || !videoState.showControls) { return null; } return /* @__PURE__ */ jsx("div", { className: "absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4", children: /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-3", children: [ /* @__PURE__ */ jsx( "button", { onClick: handlers.handlePlayPause, className: "p-2 rounded-md bg-white/20 text-white hover:bg-white/30 transition-colors", children: videoState.playing ? /* @__PURE__ */ jsx(Pause, { size: 20 }) : /* @__PURE__ */ jsx(Play, { size: 20 }) } ), /* @__PURE__ */ jsxs("div", { className: "flex gap-1", children: [ /* @__PURE__ */ jsx( "button", { onClick: () => handlers.handleFrameStep("backward"), className: "p-1 rounded text-white/80 hover:text-white transition-colors", title: "Step backward 0.1s", children: /* @__PURE__ */ jsx(SkipBack, { size: 16 }) } ), /* @__PURE__ */ jsx( "button", { onClick: () => handlers.handleFrameStep("forward"), className: "p-1 rounded text-white/80 hover:text-white transition-colors", title: "Step forward 0.1s", children: /* @__PURE__ */ jsx(SkipForward, { size: 16 }) } ) ] }), /* @__PURE__ */ jsx( "input", { type: "range", min: 0, max: videoState.duration || 0, value: videoState.currentTime, step: "0.1", onChange: handlers.handleSeek, className: "flex-1 h-2 bg-white/20 rounded-lg appearance-none cursor-pointer accent-white" } ), /* @__PURE__ */ jsxs("div", { className: "text-sm text-white font-mono min-w-[80px] text-right", children: [ formatTime(videoState.currentTime), " / ", formatTime(videoState.duration) ] }), /* @__PURE__ */ jsx( "button", { onClick: handlers.handleMute, className: "p-1 rounded text-white/80 hover:text-white transition-colors", children: videoState.muted ? /* @__PURE__ */ jsx(VolumeX, { size: 20 }) : /* @__PURE__ */ jsx(Volume2, { size: 20 }) } ), /* @__PURE__ */ jsx( "input", { type: "range", min: 0, max: 1, value: videoState.muted ? 0 : videoState.volume, step: "0.01", onChange: handlers.handleVolumeChange, className: "w-20 h-1 bg-white/20 rounded-lg appearance-none cursor-pointer accent-white" } ), /* @__PURE__ */ jsxs("div", { className: "flex gap-1 border-l border-white/20 pl-3 ml-2", children: [ onDownload && /* @__PURE__ */ jsx( "button", { onClick: onDownload, className: "p-1 rounded text-white/80 hover:text-white transition-colors", title: "Download video", children: /* @__PURE__ */ jsx(Download, { size: 16 }) } ), /* @__PURE__ */ jsx( "button", { onClick: handlers.handleToggleHorizontalFlip, className: cn( "p-1 rounded transition-colors", videoState.isHorizontalFlipped ? "text-blue-400 hover:text-blue-300" : "text-white/80 hover:text-white" ), title: "Flip horizontally", children: /* @__PURE__ */ jsxs( "svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [ /* @__PURE__ */ jsx("path", { d: "M8 3H5a2 2 0 0 0-2 2v14c0 1.1.9 2 2 2h3" }), /* @__PURE__ */ jsx("path", { d: "M16 3h3a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-3" }), /* @__PURE__ */ jsx("path", { d: "M12 20v2" }), /* @__PURE__ */ jsx("path", { d: "M12 14v2" }), /* @__PURE__ */ jsx("path", { d: "M12 8v2" }), /* @__PURE__ */ jsx("path", { d: "M12 2v2" }) ] } ) } ), /* @__PURE__ */ jsx( "button", { onClick: handlers.handleToggleVerticalFlip, className: cn( "p-1 rounded transition-colors mr-1", videoState.isVerticalFlipped ? "text-blue-400 hover:text-blue-300" : "text-white/80 hover:text-white" ), title: "Flip vertically", children: /* @__PURE__ */ jsxs( "svg", { xmlns: "http://www.w3.org/2000/svg", width: "16", height: "16", viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [ /* @__PURE__ */ jsx("path", { d: "M21 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v3" }), /* @__PURE__ */ jsx("path", { d: "M21 16v3a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-3" }), /* @__PURE__ */ jsx("path", { d: "M4 12H2" }), /* @__PURE__ */ jsx("path", { d: "M10 12H8" }), /* @__PURE__ */ jsx("path", { d: "M16 12h-2" }), /* @__PURE__ */ jsx("path", { d: "M22 12h-2" }) ] } ) } ), showMap && /* @__PURE__ */ jsx( "button", { onClick: handlers.handleToggleMap, className: cn( "p-1 rounded transition-colors mr-1", videoState.showMap ? "text-blue-400 hover:text-blue-300" : "text-white/80 hover:text-white" ), title: "Toggle map", children: /* @__PURE__ */ jsx(Map$2, { size: 16 }) } ), /* @__PURE__ */ jsx( "button", { onClick: handlers.handleFullscreen, className: "p-1 rounded text-white/80 hover:text-white transition-colors", title: "Toggle fullscreen", children: /* @__PURE__ */ jsx(Maximize2, { size: 16 }) } ) ] }) ] }) }); } var ChunkType = /* @__PURE__ */ ((ChunkType2) => { ChunkType2["THUMBNAIL"] = "thum"; ChunkType2["GPS"] = "gps "; ChunkType2["GSENSOR"] = "3gf "; ChunkType2["LTE"] = "lte "; return ChunkType2; })(ChunkType || {}); var GpsParsingStatus = /* @__PURE__ */ ((GpsParsingStatus2) => { GpsParsingStatus2["NOT_STARTED"] = "NOT_STARTED"; GpsParsingStatus2["SUCCESS"] = "SUCCESS"; GpsParsingStatus2["NO_DATA"] = "NO_DATA"; GpsParsingStatus2["INVALID_FORMAT"] = "INVALID_FORMAT"; GpsParsingStatus2["DECODE_ERROR"] = "DECODE_ERROR"; GpsParsingStatus2["ERROR"] = "ERROR"; return GpsParsingStatus2; })(GpsParsingStatus || {}); function nmeaToDecimal(coord, dir) { if (!coord || !dir) return null; const degLength = dir === "N" || dir === "S" ? 2 : 3; const degrees = parseInt(coord.slice(0, degLength), 10); const minutes = parseFloat(coord.slice(degLength)); if (isNaN(degrees) || isNaN(minutes)) return null; let decimal = degrees + minutes / 60; if (dir === "S" || dir === "W") decimal *= -1; return decimal; } function parseGpsData(data) { if (!data) { return { status: GpsParsingStatus.NO_DATA, points: [] }; } let decoded; try { decoded = atob(data); } catch (e) { throw new Error("Invalid base64 data: 디코딩에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€."); } const lines = decoded.split(/\r?\n|\r/).filter((line) => line.trim().length > 0); const pointMap = /* @__PURE__ */ new Map(); for (let i = 0; i < lines.length; i++) { const line = lines[i].trim(); const match = line.match(/^\[(\d+)\]\$(\w+),(.*)$/); if (!match) continue; const [, timestampStr, type, fieldString] = match; const timestamp = Number(timestampStr) / 1e3; const fields = fieldString.split(","); if (type === "GPGGA" || type === "GNGGA") { const latitude = nmeaToDecimal(fields[1], fields[2]); const longitude = nmeaToDecimal(fields[3], fields[4]); if (latitude !== null && longitude !== null && !isNaN(latitude) && !isNaN(longitude)) { if (!pointMap.has(timestamp)) { pointMap.set(timestamp, { timestamp, lat: latitude, lng: longitude, relativeTime: 0, speedKnots: null, speedKmh: null, course: null }); } else { const prev = pointMap.get(timestamp); pointMap.set(timestamp, { ...prev, lat: latitude, lng: longitude }); } } } else if (type === "GPRMC" || type === "GNRMC") { if (fields[1] !== "A") continue; const speedKnots = fields[6] ? parseFloat(fields[6]) : null; const course = fields[7] ? parseFloat(fields[7]) : null; const speedKmh = speedKnots !== null && !isNaN(speedKnots) ? speedKnots * 1.852 : null; if (!pointMap.has(timestamp)) { pointMap.set(timestamp, { timestamp, lat: null, lng: null, relativeTime: 0, speedKnots: isNaN(speedKnots ?? NaN) ? null : speedKnots, speedKmh, course: isNaN(course ?? NaN) ? null : course }); } else { const prev = pointMap.get(timestamp); pointMap.set(timestamp, { ...prev, speedKnots: isNaN(speedKnots ?? NaN) ? null : speedKnots, speedKmh, course: isNaN(course ?? NaN) ? null : course }); } } } const points = Array.from(pointMap.values()).sort((a, b) => a.timestamp - b.timestamp); if (points.length === 0) { return { status: GpsParsingStatus.NO_DATA, points: [] }; } const firstTime = points[0].timestamp; points.forEach((point) => { point.relativeTime = point.timestamp - firstTime; }); return { status: GpsParsingStatus.SUCCESS, points }; } var SensorParsingStatus = /* @__PURE__ */ ((SensorParsingStatus2) => { SensorParsingStatus2["NOT_STARTED"] = "NOT_STARTED"; SensorParsingStatus2["SUCCESS"] = "SUCCESS"; SensorParsingStatus2["NO_DATA"] = "NO_DATA"; SensorParsingStatus2["INVALID_FORMAT"] = "INVALID_FORMAT"; SensorParsingStatus2["DECODE_ERROR"] = "DECODE_ERROR"; SensorParsingStatus2["ERROR"] = "ERROR"; return SensorParsingStatus2; })(SensorParsingStatus || {}); function base64ToUint8Array(base64) { if (typeof window !== "undefined" && typeof window.atob === "function") { const binaryString = window.atob(base64.replace(/\s/g, "")); const len = binaryString.length; const bytes = new Uint8Array(len); for (let i = 0; i < len; i++) { bytes[i] = binaryString.charCodeAt(i); } return bytes; } else if (typeof Buffer !== "undefined") { return new Uint8Array(Buffer.from(base64, "base64")); } throw new Error("Base64 decode not supported in this environment"); } function parseSensorData(data) { if (!data) { return { status: SensorParsingStatus.NO_DATA, points: [] }; } try { const processedAccels = processGSensorBase64Legacy(data); return { status: SensorParsingStatus.SUCCESS, points: processedAccels }; } catch (error) { if (error instanceof Error && error.message.includes("Base64")) { return { status: SensorParsingStatus.DECODE_ERROR, points: [] }; } return { status: SensorParsingStatus.ERROR, points: [] }; } } function processGSensorBase64Legacy(sensorBase64) { const maxGSensorCount = 60; const data = base64ToUint8Array(sensorBase64); const dv = new DataView(data.buffer); const accels = []; let accels2 = []; const prevX = []; const prevY = []; const prevZ = []; function pushArray(arr, value, maxLen = 10) { arr.push(value); if (arr.length > maxLen) arr.shift(); } function avg(arr) { if (arr.length === 0) return 0; return arr.reduce((sum, v) => sum + v, 0) / arr.length; } for (let i = 0; i < data.length; ) { const timestamp = Math.floor(dv.getUint32(i, false) / 100) / 10; if (timestamp > maxGSensorCount) break; i += 4; const rawX = dv.getInt16(i, false); const x = Math.max(Math.min(rawX, 255), -255); i += 2; const rawY = dv.getInt16(i, false); const y = Math.max(Math.min(rawY, 255), -255); i += 2; const rawZ = dv.getInt16(i, false); const z = Math.max(Math.min(rawZ, 255), -255); i += 2; accels2.push({ timestamp, x, y, z }); } if (accels2.length < 580) { const last = accels2[accels2.length - 1]; const timeOffset = 60 - ((last == null ? void 0 : last.timestamp) ?? 60) + 2; accels2 = accels2.map((ac) => ({ ...ac, timestamp: timeOffset + ac.timestamp })); } const validAccels = accels2.filter((acc) => acc.x !== null && acc.y !== null && acc.z !== null); const avgX = avg(validAccels.map((acc) => acc.x)); const avgY = avg(validAccels.map((acc) => acc.y)); const avgZ = avg(validAccels.map((acc) => acc.z)); accels2 = accels2.map((acc) => ({ timestamp: acc.timestamp, x: acc.x !== null ? acc.x - avgX : null, y: acc.y !== null ? acc.y - avgY : null, z: acc.z !== null ? acc.z - avgZ : null })); for (const acc of accels2) { const indx = Math.floor(acc.timestamp); const { x, y, z } = acc; if (x !== null && y !== null && z !== null) { if (accels[indx]) { const existing = accels[indx]; accels[indx] = { timestamp: indx, x: existing.x !== null && Math.abs(existing.x) < Math.abs(x) ? x : existing.x, y: existing.y !== null && Math.abs(existing.y) < Math.abs(y) ? y : existing.y, z: existing.z !== null && Math.abs(existing.z) < Math.abs(z) ? z : existing.z }; } else { accels[indx] = { timestamp: indx, x, y, z }; } } } for (const acc of accels2) { const indx = Math.floor(acc.timestamp); const { x, y, z } = acc; if (x !== null && y !== null && z !== null) { const newX = x - avg(prevX); const newY = y - avg(prevY); const newZ = z - avg(prevZ); if (accels[indx]) { const existing = accels[indx]; accels[indx] = { timestamp: indx, x: existing.x !== null && Math.abs(existing.x) < Math.abs(newX) ? newX : existing.x, y: existing.y !== null && Math.abs(existing.y) < Math.abs(newY) ? newY : existing.y, z: existing.z !== null && Math.abs(existing.z) < Math.abs(newZ) ? newZ : existing.z }; } else { accels[indx] = { timestamp: indx, x: newX, y: newY, z: newZ }; } pushArray(prevX, x); pushArray(prevY, y); pushArray(prevZ, z); } } const finalAccels = accels.filter(Boolean).sort((a, b) => a.timestamp - b.timestamp); return finalAccels; } const MAX_METADATA_SIZE = 1024 * 1024; async function fetchAndParseMP4(videoUrl) { try { const controller = new AbortController(); let response; response = await fetch(videoUrl, { method: "GET", mode: "cors", credentials: "omit", cache: "no-cache", headers: { "Range": "bytes=0-1048575" // Request only first 1MB (1024*1024 bytes) }, signal: controller.signal }); if (!response.ok && response.status === 416) { controller.abort(); response = await fetch(videoUrl, { method: "GET", mode: "cors", credentials: "omit", cache: "no-cache" }); if (!response.ok) { throw new Error(`Failed to fetch MP4 file: ${response.status} ${response.statusText}`); } } else if (!response.ok) { throw new Error(`Failed to fetch MP4 file: ${response.status} ${response.statusText}`); } const blob = await response.blob(); const file = new File([blob], "video.mp4", { type: "video/mp4" }); const metadata = await extractMetadata(file); return metadata; } catch (error) { return { gps: { status: GpsParsingStatus.ERROR, points: [] }, sensor: { status: SensorParsingStatus.ERROR, points: [] }, thumbnail: null }; } } async function parseMP4UserData(file) { const bytesToRead = Math.min(MAX_METADATA_SIZE, file.size); try { const buffer = await readPartialFileAsArrayBuffer(file, 0, bytesToRead); const freeBoxOffset = findFreeBox(buffer); if (freeBoxOffset === -1) { return { success: false, error: "No user data found in MP4 file (free box not found)" }; } const chunks = extractChunks(buffer, freeBoxOffset); if (chunks.length === 0) { return { success: false, error: "No valid data chunks found in MP4 file" }; } const userData = {}; chunks.forEach((chunk) => { switch (chunk.type) { case ChunkType.THUMBNAIL: userData.thumbnail = chunk; break; case ChunkType.GPS: userData.gps = chunk; break; case ChunkType.GSENSOR: userData.gSensor = chunk; break; case ChunkType.LTE: userData.lte = chunk; break; } }); return { success: true, data: userData }; } catch (error) { return { success: false, error: error instanceof Error ? error.message : "Unknown error parsing MP4 file" }; } } async function extractMetadata(file) { Math.min(MAX_METADATA_SIZE, file.size); try { const result = await parseMP4UserData(file); if (!result.success || !result.data) { return { gps: null, sensor: null, thumbnail: null }; } let gpsResult = null; if (result.data.gps) { try { const gpsData = new TextDecoder().decode(result.data.gps.data); const gpsBase64 = btoa(gpsData); gpsResult = parseGpsData(gpsBase64); } catch (error) { gpsResult = { status: GpsParsingStatus.ERROR, points: [] }; } } else { } let sensorResult = null; if (result.data.gSensor) { try { const sensorData = result.data.gSensor.data; const sensorArray = new Uint8Array(sensorData); const sensorBase64 = btoa(Array.from(sensorArray).map((b) => String.fromCharCode(b)).join("")); sensorResult = parseSensorData(sensorBase64); } catch (error) { } } let thumbnailUrl = null; if (result.data.thumbnail) { try { const blob = new Blob([result.data.thumbnail.data], { type: "image/jpeg" }); thumbnailUrl = URL.createObjectURL(blob); } catch (error) { } } return { gps: gpsResult, sensor: sensorResult, thumbnail: thumbnailUrl }; } catch (error) { return { gps: null, sensor: null, thumbnail: null }; } } function readPartialFileAsArrayBuffer(file, start, end) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => { if (reader.result instanceof ArrayBuffer) { resolve(reader.result); } else { reject(new Error("Failed to read file as ArrayBuffer")); } }; reader.onerror = () => { reject(new Error("Error reading file")); }; const blob = file.slice(start, end); reader.readAsArrayBuffer(blob); }); } function findFreeBox(buffer) { const view = new DataView(buffer); let offset = 0; while (offset < buffer.byteLength - 8) { try { const size = view.getUint32(offset, false); const type = String.fromCharCode( view.getUint8(offset + 4), view.getUint8(offset + 5), view.getUint8(offset + 6), view.getUint8(offset + 7) ); if (type === "free") { return offset + 8; } if (size === 0) { break; } else if (size < 8) { offset += 8; } else { offset += size; } } catch (error) { offset += 8; } } return -1; } function extractChunks(buffer, freeBoxOffset) { const view = new DataView(buffer); const chunks = []; let offset = freeBoxOffset; const expectedSizes = { [ChunkType.THUMBNAIL]: 81928, // 80 KB [ChunkType.GPS]: 163848, // 160 KB [ChunkType.GSENSOR]: 61448, // 60 KB [ChunkType.LTE]: 61448 // 60 KB }; while (offset < buffer.byteLength - 8) { try { if (offset + 8 > buffer.byteLength) { break; } const size = view.getUint32(offset, false); const typeStr = String.fromCharCode( view.getUint8(offset + 4), view.getUint8(offset + 5), view.getUint8(offset + 6), view.getUint8(offset + 7) ); const isValidType = Object.values(ChunkType).includes(typeStr); if (isValidType) { const type = typeStr; if (size === expectedSizes[type]) { const dataOffset = offset + 8; const dataSize = size - 8; if (dataOffset + dataSize <= buffer.byteLength) { const data = buffer.slice(dataOffset, dataOffset + dataSize); chunks.push({ type, size: dataSize, data }); } else { } } else { } } else { } if (size === 0) { break; } else if (size < 8) { offset += 8; } else { offset += size; } } catch (error) { offset += 8; } } return chunks; } function useVideoState(options) { const { initialHorizontalFlip = false, initialVerticalFlip = false, showMap = false, showEventComponent = true } = options; const [videoState, setVideoState] = useState({ playing: false, muted: false, volume: 1, currentTime: 0, duration: 0, loading: false, error: null, isHorizontalFlipped: initialHorizontalFlip, isVerticalFlipped: initialVerticalFlip, showMap, showEventComponent, showControls: false, controlsTimer: null, autoParsedGpsPoints: null, autoParsedSensorData: null, parsingInProgress: false }); return [videoState, setVideoState]; } function useMP4MetadataParsing(enableMetadataParsing, videoUrl, setVideoState) { useEffect(() => { if (enableMetadataParsing && videoUrl) { const parseMP4Metadata = async () => { var _a, _b; setVideoState((prev) => ({ ...prev, parsingInProgress: true, autoParsedGpsPoints: null, autoParsedSensorData: null })); try { const metadata = await fetchAndParseMP4(videoUrl); const extractedGpsPoints = ((_a = metadata.gps) == null ? void 0 : _a.points) || []; const extractedSensorData = ((_b = metadata.sensor) == null ? void 0 : _b.points) || []; setVideoState((prev) => ({ ...prev, autoParsedGpsPoints: extractedGpsPoints, autoParsedSensorData: extractedSensorData, parsingInProgress: false })); } catch (error) { setVideoState((prev) => ({ ...prev, autoParsedGpsPoints: [], autoParsedSensorData: [], parsingInProgress: false })); } }; parseMP4Metadata(); } else { setVideoState((prev) => ({ ...prev, autoParsedGpsPoints: [], autoParsedSensorData: [], parsingInProgress: false })); } }, [enableMetadataParsing, videoUrl, setVideoState]); } function useVideoUrlChange(videoUrl, autoPlay, videoRef, videoState, setVideoState, onPlay) { useEffect(() => { if (videoUrl && videoRef.current) { const video = videoRef.current; let playPromise; setVideoState((prev) => ({ ...prev, loading: true, error: null, playing: false, currentTime: 0, duration: 0 })); video.src = videoUrl; video.muted = videoState.muted; video.load(); if (autoPlay) { const handleCanPlayThrough = () => { if (video.paused) { video.muted = true; playPromise = video.play().then(() => { setVideoState((prev) => ({ ...prev, playing: true })); onPlay == null ? void 0 : onPlay(); }).catch((error) => { setVideoState((prev) => ({ ...prev, playing: false, error: "Autoplay failed. Click play button to start." })); }); } video.removeEventListener("canplaythrough", handleCanPlayThrough); }; video.addEventListener("canplaythrough", handleCanPlayThrough); return () => { if (playPromise !== void 0) { playPromise.then(() => { video.pause(); }).catch(() => { }); } video.removeEventListener("canplaythrough", handleCanPlayThrough); }; } return void 0; } return void 0; }, [videoUrl, autoPlay, onPlay, videoState.muted, videoRef, setVideoState]); } function useVideoEventListeners(videoRef, setVideoState, onTimeUpdate, onDurationChange, onPlay, onPause, onEnded, onError) { useEffect(() => { const video = videoRef.current; if (!video) return; const handlers = { handleLoadedMetadata: () => { const duration = video.duration || 0; setVideoState((prev) => ({ ...prev, duration, loading: false })); onDurationChange == null ? void 0 : onDurationChange(duration); }, handleTimeUpdate: () => { const currentTime = video.currentTime || 0; setVideoState((prev) => { if (Math.abs(prev.currentTime - currentTime) >= 0.1) { return { ...prev, currentTime }; } return prev; }); onTimeUpdate == null ? void 0 : onTimeUpdate(currentTime); }, handlePlay: () => { setVideoState((prev) => ({ ...prev, playing: true })); onPlay == null ? void 0 : onPlay(); }, handlePause: () => { setVideoState((prev) => ({ ...prev, playing: false })); onPause == null ? void 0 : onPause(); }, handleEnded: () => { setVideoState((prev) => ({ ...prev, playing: false })); onEnded == null ? void 0 : onEnded(); }, handleError: () => { const errorMessage = "Video playback error occurred"; setVideoState((prev) => ({ ...prev, error: errorMessage, loading: false, playing: false })); onError == null ? void 0 : onError(errorMessage); }, handleLoadStart: () => { setVideoState((prev) => ({ ...prev, loading: true })); }, handleCanPlay: () => { setVideoState((prev) => ({ ...prev, loading: false })); } }; video.addEventListener("loadedmetadata", handlers.handleLoadedMetadata); video.addEventListener("timeupdate", handlers.handleTimeUpdate); video.addEventListener("play", handlers.handlePlay); video.addEventListener("pause", handlers.handlePause); video.addEventListener("ended", handlers.handleEnded); video.addEventListener("error", handlers.handleError); video.addEventListener("loadstart", handlers.handleLoadStart); video.addEventListener("canplay", handlers.handleCanPlay); return () => { video.removeEventListener("loadedmetadata", handlers.handleLoadedMetadata); video.removeEventListener("timeupdate", handlers.handleTimeUpdate); video.removeEventListener("play", handlers.handlePlay); video.removeEventListener("pause", handlers.handlePause); video.removeEventListener("ended", handlers.handleEnded); video.removeEventListener("error", handlers.handleError); video.removeEventListener("loadstart", handlers.handleLoadStart); video.removeEventListener("canplay", handlers.handleCanPlay); }; }, [videoRef, setVideoState, onTimeUpdate, onDurationChange, onPlay, onPause, onEnded, onError]); } function useControlsVisibility(videoUrl, setVideoState) { useEffect(() => { if (videoUrl) { setVideoState((prev) => ({ ...prev, showControls: true })); const timer = setTimeout(() => { setVideoState((prev) => ({ ...prev, showControls: false })); }, 4e3); return () => { clearTimeout(timer); }; } return void 0; }, [videoUrl, setVideoState]); } function useMutedSync(videoRef, muted, videoUrl) { useEffect(() => { const video = videoRef.current; if (video && video.muted !== muted) { video.muted = muted; } }