fleeta-components
Version:
A comprehensive React component library for fleet management applications
1,414 lines β’ 59.8 kB
JavaScript
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;
}
}