mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 06:28:12 +00:00
add tracker
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
} from "@mantine/core";
|
} from "@mantine/core";
|
||||||
import {
|
import {
|
||||||
APIProvider,
|
APIProvider,
|
||||||
|
InfoWindow,
|
||||||
Map as GoogleMap,
|
Map as GoogleMap,
|
||||||
Marker,
|
Marker,
|
||||||
useMap,
|
useMap,
|
||||||
@@ -76,6 +77,51 @@ function FitBounds({ points }: { points: LatLng[] }) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Lazily reverse-geocode a coordinate to a human address. */
|
||||||
|
function useAddress(lat: number, lng: number): string | null {
|
||||||
|
const [addr, setAddr] = useState<string | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof google === "undefined") return;
|
||||||
|
setAddr(null);
|
||||||
|
let cancelled = false;
|
||||||
|
new google.maps.Geocoder().geocode({ location: { lat, lng } }, (res, status) => {
|
||||||
|
if (cancelled) return;
|
||||||
|
setAddr(status === "OK" && res?.[0] ? res[0].formatted_address : "Unknown location");
|
||||||
|
});
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [lat, lng]);
|
||||||
|
return addr;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hover popup: label, coordinates, speed/course, and the reverse-geocoded place. */
|
||||||
|
function HoverInfo({
|
||||||
|
device,
|
||||||
|
lat,
|
||||||
|
lng,
|
||||||
|
onClose,
|
||||||
|
}: {
|
||||||
|
device: GpsDevice;
|
||||||
|
lat: number;
|
||||||
|
lng: number;
|
||||||
|
onClose: () => void;
|
||||||
|
}) {
|
||||||
|
const address = useAddress(lat, lng);
|
||||||
|
return (
|
||||||
|
<InfoWindow position={{ lat, lng }} pixelOffset={[0, -46]} onCloseClick={onClose}>
|
||||||
|
<div style={{ minWidth: 190, fontSize: 13 }}>
|
||||||
|
<div style={{ fontWeight: 600, marginBottom: 2 }}>{deviceLabel(device)}</div>
|
||||||
|
<div style={{ fontFamily: "monospace" }}>
|
||||||
|
{lat.toFixed(5)}, {lng.toFixed(5)}
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#555" }}>
|
||||||
|
{toNum(device.lastSpeed) ?? 0} km/h · {device.lastCourse ?? 0}°
|
||||||
|
</div>
|
||||||
|
<div style={{ color: "#777", marginTop: 4 }}>{address ?? "Locating…"}</div>
|
||||||
|
</div>
|
||||||
|
</InfoWindow>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Draw the selected vehicle's recent path as a polyline. */
|
/** Draw the selected vehicle's recent path as a polyline. */
|
||||||
function RouteTrail({ path }: { path: LatLng[] }) {
|
function RouteTrail({ path }: { path: LatLng[] }) {
|
||||||
const map = useMap();
|
const map = useMap();
|
||||||
@@ -97,6 +143,7 @@ export function TrackingPage() {
|
|||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||||
|
const [hoverId, setHoverId] = useState<string | null>(null);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
const [editDevice, setEditDevice] = useState<GpsDevice | null>(null);
|
const [editDevice, setEditDevice] = useState<GpsDevice | null>(null);
|
||||||
const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" });
|
const [form, setForm] = useState({ imei: "", name: "", vehicleId: "" });
|
||||||
@@ -154,17 +201,25 @@ export function TrackingPage() {
|
|||||||
[history],
|
[history],
|
||||||
);
|
);
|
||||||
|
|
||||||
const markerIcon = (d: GpsDevice, selectedFlag: boolean) => {
|
// Teardrop pin colored by state with a white truck glyph inside.
|
||||||
|
const markerIcon = (d: GpsDevice, selectedFlag: boolean): google.maps.Icon | undefined => {
|
||||||
if (typeof google === "undefined") return undefined;
|
if (typeof google === "undefined") return undefined;
|
||||||
|
const color = selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6";
|
||||||
|
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="40" height="48" viewBox="0 0 40 48">
|
||||||
|
<path d="M20 2C10 2 2 10 2 20c0 12 18 26 18 26s18-14 18-26C38 10 30 2 20 2Z" fill="${color}" stroke="#ffffff" stroke-width="1.5"/>
|
||||||
|
<g transform="translate(8,7)" fill="none" stroke="#ffffff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M14 18V6a1 1 0 0 0-1-1H2a1 1 0 0 0-1 1v11a1 1 0 0 0 1 1h1"/>
|
||||||
|
<path d="M15 18H9"/>
|
||||||
|
<path d="M19 18h2a1 1 0 0 0 1-1v-3.65a1 1 0 0 0-.22-.62l-3.48-4.35A1 1 0 0 0 17.52 8H14"/>
|
||||||
|
<circle cx="7" cy="18" r="2"/>
|
||||||
|
<circle cx="17" cy="18" r="2"/>
|
||||||
|
</g>
|
||||||
|
</svg>`;
|
||||||
return {
|
return {
|
||||||
path: "M 0,-9 L 6,9 L 0,4 L -6,9 Z", // arrow
|
url: `data:image/svg+xml,${encodeURIComponent(svg)}`,
|
||||||
rotation: d.lastCourse ?? 0,
|
scaledSize: new google.maps.Size(40, 48),
|
||||||
fillColor: selectedFlag ? freightBrand.primary : d.online ? "#2f80ed" : "#95a5a6",
|
anchor: new google.maps.Point(20, 48),
|
||||||
fillOpacity: 1,
|
};
|
||||||
strokeColor: "#ffffff",
|
|
||||||
strokeWeight: 1.5,
|
|
||||||
scale: 1.4,
|
|
||||||
} as google.maps.Symbol;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const saveMutation = useMutation({
|
const saveMutation = useMutation({
|
||||||
@@ -253,11 +308,18 @@ export function TrackingPage() {
|
|||||||
<Marker
|
<Marker
|
||||||
key={d.id}
|
key={d.id}
|
||||||
position={{ lat, lng }}
|
position={{ lat, lng }}
|
||||||
title={deviceLabel(d)}
|
title={`${deviceLabel(d)}\n${lat.toFixed(5)}, ${lng.toFixed(5)}`}
|
||||||
icon={markerIcon(d, d.id === selectedId)}
|
icon={markerIcon(d, d.id === selectedId)}
|
||||||
onClick={() => setSelectedId(d.id)}
|
onClick={() => setSelectedId(d.id)}
|
||||||
|
onMouseOver={() => setHoverId(d.id)}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
|
{(() => {
|
||||||
|
const h = positioned.find((p) => p.d.id === hoverId);
|
||||||
|
return h ? (
|
||||||
|
<HoverInfo device={h.d} lat={h.lat} lng={h.lng} onClose={() => setHoverId(null)} />
|
||||||
|
) : null;
|
||||||
|
})()}
|
||||||
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
|
<FitBounds points={positioned.map((p) => ({ lat: p.lat, lng: p.lng }))} />
|
||||||
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
|
{selected?.vehicleId && trail.length > 1 && <RouteTrail path={trail} />}
|
||||||
</GoogleMap>
|
</GoogleMap>
|
||||||
|
|||||||
Reference in New Issue
Block a user