Improve mobile CargoRadar controls
This commit is contained in:
@@ -11,6 +11,10 @@ const state = {
|
|||||||
programmaticMapChangeTimer: 0,
|
programmaticMapChangeTimer: 0,
|
||||||
pendingLocationUpdates: new Map(),
|
pendingLocationUpdates: new Map(),
|
||||||
locationUpdateBatchTimer: 0,
|
locationUpdateBatchTimer: 0,
|
||||||
|
mapResizeTimer: 0,
|
||||||
|
mapZoomMode: "",
|
||||||
|
mapLayer: "road",
|
||||||
|
leafletTileLayer: null,
|
||||||
drivers: new Map(),
|
drivers: new Map(),
|
||||||
locations: new Map(),
|
locations: new Map(),
|
||||||
selectedDriverId: "",
|
selectedDriverId: "",
|
||||||
@@ -83,6 +87,9 @@ const elements = {
|
|||||||
mapActiveCount: document.getElementById("mapActiveCount"),
|
mapActiveCount: document.getElementById("mapActiveCount"),
|
||||||
mapEtaSummary: document.getElementById("mapEtaSummary"),
|
mapEtaSummary: document.getElementById("mapEtaSummary"),
|
||||||
mapUpdatedAt: document.getElementById("mapUpdatedAt"),
|
mapUpdatedAt: document.getElementById("mapUpdatedAt"),
|
||||||
|
mapLayerButtons: [...document.querySelectorAll("[data-map-layer]")],
|
||||||
|
mapZoomButtons: [...document.querySelectorAll("[data-map-zoom]")],
|
||||||
|
mapExpandButton: document.getElementById("mapExpandButton"),
|
||||||
routeFocusPanel: document.getElementById("routeFocusPanel"),
|
routeFocusPanel: document.getElementById("routeFocusPanel"),
|
||||||
routeFocusTitle: document.getElementById("routeFocusTitle"),
|
routeFocusTitle: document.getElementById("routeFocusTitle"),
|
||||||
routeFocusMeta: document.getElementById("routeFocusMeta"),
|
routeFocusMeta: document.getElementById("routeFocusMeta"),
|
||||||
@@ -171,9 +178,11 @@ async function initializeMap() {
|
|||||||
});
|
});
|
||||||
state.mapProvider = "kakao";
|
state.mapProvider = "kakao";
|
||||||
|
|
||||||
state.map.addControl(new kakao.maps.ZoomControl(), kakao.maps.ControlPosition.RIGHT);
|
enableMapGestures();
|
||||||
state.map.addControl(new kakao.maps.MapTypeControl(), kakao.maps.ControlPosition.TOPRIGHT);
|
applyMapLayer(state.mapLayer);
|
||||||
|
syncMapControls();
|
||||||
bindMapInteractionTracking();
|
bindMapInteractionTracking();
|
||||||
|
updateMapZoomMode();
|
||||||
hideMapMessage();
|
hideMapMessage();
|
||||||
updateInitialMapFit();
|
updateInitialMapFit();
|
||||||
return;
|
return;
|
||||||
@@ -217,20 +226,166 @@ async function initializeLeafletMap() {
|
|||||||
state.mapProvider = "leaflet";
|
state.mapProvider = "leaflet";
|
||||||
state.map = L.map("map", {
|
state.map = L.map("map", {
|
||||||
attributionControl: true,
|
attributionControl: true,
|
||||||
zoomControl: true
|
dragging: true,
|
||||||
|
touchZoom: true,
|
||||||
|
scrollWheelZoom: true,
|
||||||
|
doubleClickZoom: true,
|
||||||
|
zoomControl: false
|
||||||
}).setView([36.45, 127.85], 7);
|
}).setView([36.45, 127.85], 7);
|
||||||
|
|
||||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
enableMapGestures();
|
||||||
maxZoom: 19,
|
state.leafletTileLayer = createLeafletTileLayer(state.mapLayer).addTo(state.map);
|
||||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
syncMapControls();
|
||||||
}).addTo(state.map);
|
|
||||||
|
|
||||||
bindMapInteractionTracking();
|
bindMapInteractionTracking();
|
||||||
|
updateMapZoomMode();
|
||||||
showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중");
|
showMapMessage("카카오맵 권한 전까지 OpenStreetMap으로 표시 중");
|
||||||
window.setTimeout(hideMapMessage, 4500);
|
window.setTimeout(hideMapMessage, 4500);
|
||||||
updateInitialMapFit();
|
updateInitialMapFit();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createLeafletTileLayer(layer) {
|
||||||
|
if (layer === "sky") {
|
||||||
|
return L.tileLayer(
|
||||||
|
"https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}",
|
||||||
|
{
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: "Tiles © Esri"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return L.tileLayer("https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png", {
|
||||||
|
maxZoom: 19,
|
||||||
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors © <a href="https://carto.com/attributions">CARTO</a>'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyMapLayer(layer) {
|
||||||
|
const nextLayer = layer === "sky" ? "sky" : "road";
|
||||||
|
state.mapLayer = nextLayer;
|
||||||
|
|
||||||
|
if (state.mapProvider === "kakao" && state.map && window.kakao?.maps) {
|
||||||
|
const skyTypeId = kakao.maps.MapTypeId.SKYVIEW || kakao.maps.MapTypeId.HYBRID || kakao.maps.MapTypeId.ROADMAP;
|
||||||
|
state.map.setMapTypeId(nextLayer === "sky" ? skyTypeId : kakao.maps.MapTypeId.ROADMAP);
|
||||||
|
redrawKakaoMap();
|
||||||
|
if (state.locations.size > 0) {
|
||||||
|
window.setTimeout(() => updateMarkers(), 80);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.mapProvider === "leaflet" && state.map && window.L?.tileLayer) {
|
||||||
|
if (state.leafletTileLayer) {
|
||||||
|
state.leafletTileLayer.remove();
|
||||||
|
}
|
||||||
|
state.leafletTileLayer = createLeafletTileLayer(nextLayer).addTo(state.map);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncMapControls();
|
||||||
|
}
|
||||||
|
|
||||||
|
function redrawKakaoMap() {
|
||||||
|
if (state.mapProvider !== "kakao" || !state.map) return;
|
||||||
|
|
||||||
|
if (typeof state.map.relayout === "function") {
|
||||||
|
state.map.relayout();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof state.map.getCenter === "function" && typeof state.map.setCenter === "function") {
|
||||||
|
state.map.setCenter(state.map.getCenter());
|
||||||
|
}
|
||||||
|
|
||||||
|
enableMapGestures();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resizeMapViewport() {
|
||||||
|
if (!state.map || !state.mapProvider) return;
|
||||||
|
|
||||||
|
if (state.mapProvider === "kakao") {
|
||||||
|
redrawKakaoMap();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.mapProvider === "leaflet" && typeof state.map.invalidateSize === "function") {
|
||||||
|
state.map.invalidateSize();
|
||||||
|
}
|
||||||
|
|
||||||
|
enableMapGestures();
|
||||||
|
}
|
||||||
|
|
||||||
|
function enableMapGestures() {
|
||||||
|
if (!state.map || !state.mapProvider) return;
|
||||||
|
|
||||||
|
if (state.mapProvider === "kakao") {
|
||||||
|
if (typeof state.map.setDraggable === "function") {
|
||||||
|
state.map.setDraggable(true);
|
||||||
|
}
|
||||||
|
if (typeof state.map.setZoomable === "function") {
|
||||||
|
state.map.setZoomable(true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.mapProvider !== "leaflet") return;
|
||||||
|
|
||||||
|
const gestureHandlers = [
|
||||||
|
state.map.dragging,
|
||||||
|
state.map.touchZoom,
|
||||||
|
state.map.scrollWheelZoom,
|
||||||
|
state.map.doubleClickZoom,
|
||||||
|
state.map.boxZoom,
|
||||||
|
state.map.keyboard,
|
||||||
|
state.map.tap
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const handler of gestureHandlers) {
|
||||||
|
if (typeof handler?.enable === "function") {
|
||||||
|
handler.enable();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function adjustMapZoom(direction) {
|
||||||
|
if (!state.map || !state.mapProvider) return;
|
||||||
|
|
||||||
|
state.hasFittedInitialLocations = true;
|
||||||
|
|
||||||
|
if (state.mapProvider === "kakao" && typeof state.map.getLevel === "function") {
|
||||||
|
const delta = direction === "in" ? -1 : 1;
|
||||||
|
const nextLevel = Math.max(1, Math.min(14, state.map.getLevel() + delta));
|
||||||
|
state.map.setLevel(nextLevel);
|
||||||
|
updateMapZoomMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.mapProvider === "leaflet") {
|
||||||
|
if (direction === "in") {
|
||||||
|
state.map.zoomIn();
|
||||||
|
} else {
|
||||||
|
state.map.zoomOut();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMapControls() {
|
||||||
|
for (const button of elements.mapLayerButtons) {
|
||||||
|
const isActive = button.dataset.mapLayer === state.mapLayer;
|
||||||
|
button.classList.toggle("is-active", isActive);
|
||||||
|
button.setAttribute("aria-pressed", String(isActive));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (elements.mapPanel) {
|
||||||
|
elements.mapPanel.classList.toggle("is-layer-sky", state.mapLayer === "sky");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function setMapExpanded(isExpanded) {
|
||||||
|
document.body.classList.toggle("is-map-expanded", isExpanded);
|
||||||
|
elements.mapExpandButton.textContent = isExpanded ? "목록 보기" : "지도 크게";
|
||||||
|
elements.mapExpandButton.setAttribute("aria-pressed", String(isExpanded));
|
||||||
|
window.setTimeout(resizeMapViewport, 80);
|
||||||
|
}
|
||||||
|
|
||||||
function loadLeafletAssets() {
|
function loadLeafletAssets() {
|
||||||
if (window.L?.map) return Promise.resolve();
|
if (window.L?.map) return Promise.resolve();
|
||||||
|
|
||||||
@@ -288,6 +443,29 @@ function bindActions() {
|
|||||||
elements.refreshButton.addEventListener("click", refreshLatestLocations);
|
elements.refreshButton.addEventListener("click", refreshLatestLocations);
|
||||||
elements.demoButton.addEventListener("click", postDemoLocation);
|
elements.demoButton.addEventListener("click", postDemoLocation);
|
||||||
elements.logoutButton.addEventListener("click", showLoginScreen);
|
elements.logoutButton.addEventListener("click", showLoginScreen);
|
||||||
|
for (const button of elements.mapLayerButtons) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
state.hasFittedInitialLocations = true;
|
||||||
|
applyMapLayer(button.dataset.mapLayer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
for (const button of elements.mapZoomButtons) {
|
||||||
|
button.addEventListener("click", () => {
|
||||||
|
adjustMapZoom(button.dataset.mapZoom);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
elements.mapExpandButton.addEventListener("click", () => {
|
||||||
|
setMapExpanded(!document.body.classList.contains("is-map-expanded"));
|
||||||
|
});
|
||||||
|
window.addEventListener("resize", () => {
|
||||||
|
if (state.mapResizeTimer) {
|
||||||
|
window.clearTimeout(state.mapResizeTimer);
|
||||||
|
}
|
||||||
|
state.mapResizeTimer = window.setTimeout(() => {
|
||||||
|
state.mapResizeTimer = 0;
|
||||||
|
resizeMapViewport();
|
||||||
|
}, 120);
|
||||||
|
});
|
||||||
elements.locationsTab.addEventListener("click", () => setActiveView("locations"));
|
elements.locationsTab.addEventListener("click", () => setActiveView("locations"));
|
||||||
elements.driversTab.addEventListener("click", () => setActiveView("drivers"));
|
elements.driversTab.addEventListener("click", () => setActiveView("drivers"));
|
||||||
elements.driverForm.addEventListener("submit", submitDriverForm);
|
elements.driverForm.addEventListener("submit", submitDriverForm);
|
||||||
@@ -337,6 +515,7 @@ function submitLoginForm(event) {
|
|||||||
function showLoginScreen(options = {}) {
|
function showLoginScreen(options = {}) {
|
||||||
clearLaunchSequence();
|
clearLaunchSequence();
|
||||||
clearStoredLoginState();
|
clearStoredLoginState();
|
||||||
|
setMapExpanded(false);
|
||||||
if (state.eventSource) {
|
if (state.eventSource) {
|
||||||
state.eventSource.close();
|
state.eventSource.close();
|
||||||
state.eventSource = null;
|
state.eventSource = null;
|
||||||
@@ -994,18 +1173,62 @@ function bindMapInteractionTracking() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleZoomChange = () => {
|
||||||
|
markUserInteraction();
|
||||||
|
updateMapZoomMode();
|
||||||
|
if (state.mapProvider === "kakao" && state.markers.size > 0) {
|
||||||
|
window.setTimeout(() => updateMarkers(), 80);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
if (state.mapProvider === "kakao") {
|
if (state.mapProvider === "kakao") {
|
||||||
kakao.maps.event.addListener(state.map, "dragstart", markUserInteraction);
|
kakao.maps.event.addListener(state.map, "dragstart", markUserInteraction);
|
||||||
kakao.maps.event.addListener(state.map, "zoom_changed", markUserInteraction);
|
kakao.maps.event.addListener(state.map, "zoom_changed", handleZoomChange);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (state.mapProvider === "leaflet") {
|
if (state.mapProvider === "leaflet") {
|
||||||
state.map.on("dragstart", markUserInteraction);
|
state.map.on("dragstart", markUserInteraction);
|
||||||
state.map.on("zoomstart", markUserInteraction);
|
state.map.on("zoomstart", markUserInteraction);
|
||||||
|
state.map.on("zoomend", handleZoomChange);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updateMapZoomMode() {
|
||||||
|
if (!elements.mapPanel || !state.map || !state.mapProvider) return;
|
||||||
|
|
||||||
|
const mode = getMapZoomMode();
|
||||||
|
if (state.mapZoomMode === mode) return;
|
||||||
|
|
||||||
|
state.mapZoomMode = mode;
|
||||||
|
elements.mapPanel.dataset.zoomMode = mode;
|
||||||
|
elements.mapPanel.classList.toggle("is-zoom-far", mode === "far");
|
||||||
|
elements.mapPanel.classList.toggle("is-zoom-mid", mode === "mid");
|
||||||
|
elements.mapPanel.classList.toggle("is-zoom-near", mode === "near");
|
||||||
|
|
||||||
|
if (state.mapProvider === "leaflet" && state.markers.size > 0) {
|
||||||
|
updateMarkers();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMapZoomMode() {
|
||||||
|
if (state.mapProvider === "kakao" && typeof state.map.getLevel === "function") {
|
||||||
|
const level = state.map.getLevel();
|
||||||
|
if (level <= 5) return "near";
|
||||||
|
if (level <= 9) return "mid";
|
||||||
|
return "far";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.mapProvider === "leaflet" && typeof state.map.getZoom === "function") {
|
||||||
|
const zoom = state.map.getZoom();
|
||||||
|
if (zoom >= 14) return "near";
|
||||||
|
if (zoom >= 10) return "mid";
|
||||||
|
return "far";
|
||||||
|
}
|
||||||
|
|
||||||
|
return "mid";
|
||||||
|
}
|
||||||
|
|
||||||
function runProgrammaticMapChange(callback) {
|
function runProgrammaticMapChange(callback) {
|
||||||
state.isProgrammaticMapChange = true;
|
state.isProgrammaticMapChange = true;
|
||||||
window.clearTimeout(state.programmaticMapChangeTimer);
|
window.clearTimeout(state.programmaticMapChangeTimer);
|
||||||
@@ -1043,7 +1266,8 @@ function updateKakaoMarker(location) {
|
|||||||
overlay: new kakao.maps.CustomOverlay({
|
overlay: new kakao.maps.CustomOverlay({
|
||||||
position: kakaoPosition,
|
position: kakaoPosition,
|
||||||
content: markerElement,
|
content: markerElement,
|
||||||
yAnchor: 1
|
xAnchor: 0.5,
|
||||||
|
yAnchor: 0.5
|
||||||
}),
|
}),
|
||||||
element: markerElement,
|
element: markerElement,
|
||||||
currentPosition: targetPosition
|
currentPosition: targetPosition
|
||||||
@@ -1052,6 +1276,7 @@ function updateKakaoMarker(location) {
|
|||||||
state.markers.set(location.driverId, marker);
|
state.markers.set(location.driverId, marker);
|
||||||
} else {
|
} else {
|
||||||
marker.overlay.setContent(markerElement);
|
marker.overlay.setContent(markerElement);
|
||||||
|
marker.overlay.setMap(state.map);
|
||||||
marker.element = markerElement;
|
marker.element = markerElement;
|
||||||
animateMarkerPosition(marker, targetPosition);
|
animateMarkerPosition(marker, targetPosition);
|
||||||
}
|
}
|
||||||
@@ -1065,12 +1290,13 @@ function updateLeafletMarker(location) {
|
|||||||
const existingMarker = state.markers.get(location.driverId);
|
const existingMarker = state.markers.get(location.driverId);
|
||||||
const markerHeading = resolveMarkerHeading(location, existingMarker?.currentPosition, targetPosition);
|
const markerHeading = resolveMarkerHeading(location, existingMarker?.currentPosition, targetPosition);
|
||||||
const selectedClass = state.selectedDriverId === location.driverId ? "is-selected" : "";
|
const selectedClass = state.selectedDriverId === location.driverId ? "is-selected" : "";
|
||||||
|
const iconMetrics = getTruckMarkerIconMetrics();
|
||||||
const icon = L.divIcon({
|
const icon = L.divIcon({
|
||||||
className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className} ${selectedClass}`,
|
className: `leaflet-truck-marker truck-marker ${status.key} ${vehicleType.className} ${selectedClass}`,
|
||||||
html: createTruckMarkerHtml(location, vehicleType, markerHeading),
|
html: createTruckMarkerHtml(location, vehicleType, markerHeading),
|
||||||
iconSize: [104, 68],
|
iconSize: iconMetrics.size,
|
||||||
iconAnchor: [52, 64],
|
iconAnchor: iconMetrics.anchor,
|
||||||
popupAnchor: [0, -50]
|
popupAnchor: iconMetrics.popupAnchor
|
||||||
});
|
});
|
||||||
|
|
||||||
let marker = existingMarker;
|
let marker = existingMarker;
|
||||||
@@ -1092,6 +1318,30 @@ function updateLeafletMarker(location) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTruckMarkerIconMetrics() {
|
||||||
|
if (state.mapZoomMode === "far") {
|
||||||
|
return {
|
||||||
|
size: [30, 30],
|
||||||
|
anchor: [15, 15],
|
||||||
|
popupAnchor: [0, -20]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.mapZoomMode === "mid") {
|
||||||
|
return {
|
||||||
|
size: [76, 46],
|
||||||
|
anchor: [38, 23],
|
||||||
|
popupAnchor: [0, -28]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
size: [98, 58],
|
||||||
|
anchor: [49, 29],
|
||||||
|
popupAnchor: [0, -34]
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function createTruckMarkerElement(location, status, heading) {
|
function createTruckMarkerElement(location, status, heading) {
|
||||||
const vehicleType = getVehicleType(location);
|
const vehicleType = getVehicleType(location);
|
||||||
const markerElement = document.createElement("button");
|
const markerElement = document.createElement("button");
|
||||||
@@ -1111,112 +1361,132 @@ function createTruckMarkerElement(location, status, heading) {
|
|||||||
function createTruckMarkerHtml(location, vehicleType = getVehicleType(location), heading = getLocationHeading(location)) {
|
function createTruckMarkerHtml(location, vehicleType = getVehicleType(location), heading = getLocationHeading(location)) {
|
||||||
const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4);
|
const label = location.vehicleNo ? location.vehicleNo.slice(-4) : location.driverId.slice(0, 4);
|
||||||
const rotation = normalizeMarkerRotation(heading);
|
const rotation = normalizeMarkerRotation(heading);
|
||||||
const vehiclePictogram = createVehiclePictogramSvg(vehicleType.className);
|
|
||||||
return `
|
return `
|
||||||
<span class="truck-marker__beacon" style="--truck-heading: ${rotation}deg" aria-hidden="true">
|
<span class="truck-marker__heading" style="--truck-heading: ${rotation}deg" aria-hidden="true">
|
||||||
<svg class="truck-marker__beacon-icon" viewBox="0 0 44 52" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__heading-icon" viewBox="0 0 54 54" focusable="false" aria-hidden="true">
|
||||||
<path class="beacon-shadow" d="M22 4 39 45 22 36 5 45z"></path>
|
<path class="heading-glow" d="M27 3 36 22 27 18 18 22z"></path>
|
||||||
<path class="beacon-body" d="M22 4 39 45 22 36 5 45z"></path>
|
<path class="heading-tip" d="M27 5.5 34 20 27 16.6 20 20z"></path>
|
||||||
<path class="beacon-spine" d="M22 13v25"></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</span>
|
</span>
|
||||||
<span class="truck-marker__body-icon" aria-hidden="true">${vehiclePictogram}</span>
|
<span class="truck-marker__badge">
|
||||||
<span class="truck-marker__status" aria-hidden="true"></span>
|
${createVehicleTopSvg(vehicleType.className)}
|
||||||
<span class="truck-marker__identity">
|
|
||||||
<span class="truck-marker__label-icon" aria-hidden="true">${vehiclePictogram}</span>
|
|
||||||
<span class="truck-marker__plate">${escapeHtml(label)}</span>
|
<span class="truck-marker__plate">${escapeHtml(label)}</span>
|
||||||
<span class="truck-marker__type">${escapeHtml(vehicleType.shortLabel)}</span>
|
|
||||||
</span>
|
</span>
|
||||||
|
<span class="truck-marker__status" aria-hidden="true"></span>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createVehiclePictogramSvg(typeClass) {
|
function createVehicleTopSvg(typeClass) {
|
||||||
if (typeClass === "type-trailer") {
|
if (typeClass === "type-trailer") {
|
||||||
return `
|
return `
|
||||||
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__photo truck-marker__photo--trailer" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
|
||||||
<rect class="icon-load" x="3" y="7" width="23" height="7" rx="2"></rect>
|
<ellipse class="photo-shadow" cx="45" cy="31.5" rx="41" ry="3.8"></ellipse>
|
||||||
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path>
|
<path class="photo-trailer" d="M6 13h54c2.2 0 3.8 1.6 3.8 3.8v10.4H6z"></path>
|
||||||
<path class="icon-line" d="M26 13h3"></path>
|
<path class="photo-bedline" d="M9 17h50M9 22h50"></path>
|
||||||
<circle class="icon-wheel" cx="9" cy="17" r="2.2"></circle>
|
<path class="photo-coupler" d="M64 23h7"></path>
|
||||||
<circle class="icon-wheel" cx="23" cy="17" r="2.2"></circle>
|
<path class="photo-cab" d="M70 17h9.5l8.5 7.7V30H70z"></path>
|
||||||
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle>
|
<path class="photo-window" d="M75 19.5h4.2l4.8 4.3h-9z"></path>
|
||||||
|
<path class="photo-light" d="M86.7 26h2.8v1.8h-2.8z"></path>
|
||||||
|
<circle class="photo-wheel" cx="14" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="29" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="76" cy="29.5" r="3.1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeClass === "type-container") {
|
if (typeClass === "type-container") {
|
||||||
return `
|
return `
|
||||||
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__photo truck-marker__photo--container" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
|
||||||
<rect class="icon-load" x="3" y="6" width="24" height="9" rx="1.5"></rect>
|
<ellipse class="photo-shadow" cx="45" cy="31.5" rx="41" ry="3.8"></ellipse>
|
||||||
<path class="icon-cab" d="M29 9h5l4 4v4h-9z"></path>
|
<rect class="photo-container" x="5" y="10" width="59" height="17" rx="2"></rect>
|
||||||
<path class="icon-line" d="M8 7v7M13 7v7M18 7v7M23 7v7"></path>
|
<path class="photo-ribs" d="M12 11.5v14M19 11.5v14M26 11.5v14M33 11.5v14M40 11.5v14M47 11.5v14M54 11.5v14"></path>
|
||||||
<circle class="icon-wheel" cx="10" cy="17" r="2.2"></circle>
|
<path class="photo-coupler" d="M64 23h7"></path>
|
||||||
<circle class="icon-wheel" cx="24" cy="17" r="2.2"></circle>
|
<path class="photo-cab" d="M70 17h9.5l8.5 7.7V30H70z"></path>
|
||||||
<circle class="icon-wheel" cx="35" cy="17" r="2.2"></circle>
|
<path class="photo-window" d="M75 19.5h4.2l4.8 4.3h-9z"></path>
|
||||||
|
<path class="photo-light" d="M86.7 26h2.8v1.8h-2.8z"></path>
|
||||||
|
<circle class="photo-wheel" cx="17" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="52" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="76" cy="29.5" r="3.1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeClass === "type-box") {
|
if (typeClass === "type-box") {
|
||||||
return `
|
return `
|
||||||
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__photo truck-marker__photo--box" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
|
||||||
<rect class="icon-load" x="6" y="4" width="20" height="12" rx="2"></rect>
|
<ellipse class="photo-shadow" cx="43" cy="31.5" rx="38" ry="3.7"></ellipse>
|
||||||
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path>
|
<rect class="photo-box" x="9" y="8" width="42" height="19" rx="2.2"></rect>
|
||||||
<path class="icon-line" d="M11 8h10M11 12h8"></path>
|
<path class="photo-box-roof" d="M10 10h40"></path>
|
||||||
<circle class="icon-wheel" cx="12" cy="17" r="2.2"></circle>
|
<path class="photo-cab" d="M55 16h12l8.5 7.7V30H55z"></path>
|
||||||
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle>
|
<path class="photo-window" d="M60.3 18.8h5.2l5 4.8H59z"></path>
|
||||||
|
<path class="photo-door" d="M55 24h19"></path>
|
||||||
|
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="64" cy="29.5" r="3.1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeClass === "type-van") {
|
if (typeClass === "type-van") {
|
||||||
return `
|
return `
|
||||||
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__photo truck-marker__photo--van" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
|
||||||
<path class="icon-load" d="M6 16v-4c1-5 5-8 10-8h9c5 0 8 4 9 9l3 2v2H6z"></path>
|
<ellipse class="photo-shadow" cx="42" cy="31.5" rx="36" ry="3.6"></ellipse>
|
||||||
<path class="icon-window" d="M15 6h7v6H11c1-3 2-5 4-6zM24 6h3c3 1 5 3 6 6h-9z"></path>
|
<path class="photo-bed" d="M7 17h34v10H7z"></path>
|
||||||
<circle class="icon-wheel" cx="14" cy="17" r="2.2"></circle>
|
<path class="photo-rail" d="M9 14.5v12M39 14.5v12M9 18h30"></path>
|
||||||
<circle class="icon-wheel" cx="30" cy="17" r="2.2"></circle>
|
<path class="photo-cab photo-cabover" d="M44 12h16.5c5 0 8.8 3.8 8.8 8.8V30H44z"></path>
|
||||||
|
<path class="photo-window" d="M50 15h9.3l5.3 6.2H50z"></path>
|
||||||
|
<path class="photo-light" d="M67.2 24.5h2.8v1.8h-2.8z"></path>
|
||||||
|
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="56" cy="29.5" r="3.1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeClass === "type-cold") {
|
if (typeClass === "type-cold") {
|
||||||
return `
|
return `
|
||||||
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__photo truck-marker__photo--cold" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
|
||||||
<rect class="icon-load" x="6" y="5" width="20" height="11" rx="2"></rect>
|
<ellipse class="photo-shadow" cx="43" cy="31.5" rx="38" ry="3.7"></ellipse>
|
||||||
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path>
|
<rect class="photo-box" x="9" y="8" width="42" height="19" rx="2.2"></rect>
|
||||||
<path class="icon-snow" d="M16 7v7M12 9l8 4M20 9l-8 4"></path>
|
<rect class="photo-reefer" x="43" y="10.5" width="6" height="7" rx="1"></rect>
|
||||||
<circle class="icon-wheel" cx="12" cy="17" r="2.2"></circle>
|
<path class="photo-snow" d="M28 13v8M24.5 15l7 4M31.5 15l-7 4M25.5 12.5 28 15l2.5-2.5M25.5 21.5 28 19l2.5 2.5"></path>
|
||||||
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle>
|
<path class="photo-cab" d="M55 16h12l8.5 7.7V30H55z"></path>
|
||||||
|
<path class="photo-window" d="M60.3 18.8h5.2l5 4.8H59z"></path>
|
||||||
|
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="64" cy="29.5" r="3.1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (typeClass === "type-wing") {
|
if (typeClass === "type-wing") {
|
||||||
return `
|
return `
|
||||||
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__photo truck-marker__photo--wing" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
|
||||||
<path class="icon-wing" d="M8 6h21l-6 8H4z"></path>
|
<ellipse class="photo-shadow" cx="43" cy="31.5" rx="38" ry="3.7"></ellipse>
|
||||||
<rect class="icon-load" x="8" y="10" width="18" height="6" rx="2"></rect>
|
<rect class="photo-box" x="9" y="8" width="42" height="19" rx="2.2"></rect>
|
||||||
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path>
|
<path class="photo-wing" d="M11 10h38L39 24H4z"></path>
|
||||||
<circle class="icon-wheel" cx="13" cy="17" r="2.2"></circle>
|
<path class="photo-wing-line" d="M13 12 8 23M47 12 37 24"></path>
|
||||||
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle>
|
<path class="photo-cab" d="M55 16h12l8.5 7.7V30H55z"></path>
|
||||||
|
<path class="photo-window" d="M60.3 18.8h5.2l5 4.8H59z"></path>
|
||||||
|
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="64" cy="29.5" r="3.1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<svg class="truck-marker__vehicle-icon" viewBox="0 0 40 22" focusable="false" aria-hidden="true">
|
<svg class="truck-marker__photo truck-marker__photo--cargo" viewBox="0 0 96 38" focusable="false" aria-hidden="true">
|
||||||
<rect class="icon-load" x="6" y="7" width="20" height="9" rx="2"></rect>
|
<ellipse class="photo-shadow" cx="42" cy="31.5" rx="37" ry="3.6"></ellipse>
|
||||||
<path class="icon-cab" d="M28 9h5l4 4v4h-9z"></path>
|
<path class="photo-bed" d="M7 15.5h39v11.5H7z"></path>
|
||||||
<path class="icon-line" d="M11 10h10M11 13h8"></path>
|
<path class="photo-rail" d="M9 13.5v13M44 13.5v13M9 18h35M9 24h35"></path>
|
||||||
<circle class="icon-wheel" cx="12" cy="17" r="2.2"></circle>
|
<path class="photo-cab" d="M50 15h13l8.7 8V30H50z"></path>
|
||||||
<circle class="icon-wheel" cx="34" cy="17" r="2.2"></circle>
|
<path class="photo-window" d="M55.5 17.5h5.5l5.2 5.2H54.7z"></path>
|
||||||
|
<path class="photo-light" d="M69.5 24.8h2.8v1.8h-2.8z"></path>
|
||||||
|
<circle class="photo-wheel" cx="18" cy="29.5" r="3.1"></circle>
|
||||||
|
<circle class="photo-wheel" cx="58" cy="29.5" r="3.1"></circle>
|
||||||
</svg>
|
</svg>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMarkerHeading(location, fromPosition, targetPosition) {
|
function resolveMarkerHeading(location, fromPosition, targetPosition) {
|
||||||
const routeHeading = getRouteStartHeading(location);
|
const routeHeading = getRouteForwardHeading(location, targetPosition);
|
||||||
if (routeHeading !== null) return routeHeading;
|
if (routeHeading !== null) return routeHeading;
|
||||||
|
|
||||||
if (fromPosition && targetPosition && calculateDistanceKm(fromPosition, targetPosition) >= 0.003) {
|
if (fromPosition && targetPosition && calculateDistanceKm(fromPosition, targetPosition) >= 0.003) {
|
||||||
@@ -1226,10 +1496,41 @@ function resolveMarkerHeading(location, fromPosition, targetPosition) {
|
|||||||
return getLocationHeading(location);
|
return getLocationHeading(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRouteStartHeading(location) {
|
function getRouteForwardHeading(location, currentPosition) {
|
||||||
const routePath = getRoutePath(location);
|
const routePath = getRoutePath(location);
|
||||||
if (routePath.length < 2) return null;
|
if (routePath.length < 2) return null;
|
||||||
|
|
||||||
|
const current = currentPosition || normalizeDisplayRoutePoint({
|
||||||
|
latitude: location.latitude,
|
||||||
|
longitude: location.longitude
|
||||||
|
});
|
||||||
|
if (!current) return getRouteStartHeading(routePath);
|
||||||
|
|
||||||
|
let nearestIndex = 0;
|
||||||
|
let nearestDistance = Infinity;
|
||||||
|
for (let index = 0; index < routePath.length; index += 1) {
|
||||||
|
const distance = calculateDistanceKm(current, routePath[index]);
|
||||||
|
if (distance < nearestDistance) {
|
||||||
|
nearestIndex = index;
|
||||||
|
nearestDistance = distance;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let index = nearestIndex + 1; index < routePath.length; index += 1) {
|
||||||
|
const to = routePath[index];
|
||||||
|
if (calculateDistanceKm(current, to) >= 0.008) {
|
||||||
|
return calculateBearing(current, to);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nearestIndex > 0) {
|
||||||
|
return calculateBearing(routePath[nearestIndex - 1], routePath[nearestIndex]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return getRouteStartHeading(routePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRouteStartHeading(routePath) {
|
||||||
const from = routePath[0];
|
const from = routePath[0];
|
||||||
for (let index = 1; index < routePath.length; index += 1) {
|
for (let index = 1; index < routePath.length; index += 1) {
|
||||||
const to = routePath[index];
|
const to = routePath[index];
|
||||||
@@ -1248,7 +1549,7 @@ function getLocationHeading(location) {
|
|||||||
|
|
||||||
function normalizeMarkerRotation(heading) {
|
function normalizeMarkerRotation(heading) {
|
||||||
const normalizedHeading = ((Number(heading) % 360) + 360) % 360;
|
const normalizedHeading = ((Number(heading) % 360) + 360) % 360;
|
||||||
return Math.round(normalizedHeading - 90);
|
return Math.round(normalizedHeading);
|
||||||
}
|
}
|
||||||
|
|
||||||
function animateMarkerPosition(marker, targetPosition) {
|
function animateMarkerPosition(marker, targetPosition) {
|
||||||
@@ -1401,7 +1702,7 @@ function renderRoutePreview(location, options = {}) {
|
|||||||
state.routeOverlay = new kakao.maps.Polyline({
|
state.routeOverlay = new kakao.maps.Polyline({
|
||||||
path,
|
path,
|
||||||
strokeWeight: 5,
|
strokeWeight: 5,
|
||||||
strokeColor: "#1f6feb",
|
strokeColor: "#0b8f9c",
|
||||||
strokeOpacity: 0.86,
|
strokeOpacity: 0.86,
|
||||||
strokeStyle: "solid"
|
strokeStyle: "solid"
|
||||||
});
|
});
|
||||||
@@ -1418,7 +1719,7 @@ function renderRoutePreview(location, options = {}) {
|
|||||||
if (state.mapProvider === "leaflet") {
|
if (state.mapProvider === "leaflet") {
|
||||||
const path = routePath.map((point) => [point.latitude, point.longitude]);
|
const path = routePath.map((point) => [point.latitude, point.longitude]);
|
||||||
state.routeOverlay = L.polyline(path, {
|
state.routeOverlay = L.polyline(path, {
|
||||||
color: "#1f6feb",
|
color: "#0b8f9c",
|
||||||
weight: 5,
|
weight: 5,
|
||||||
opacity: 0.86,
|
opacity: 0.86,
|
||||||
lineCap: "round",
|
lineCap: "round",
|
||||||
@@ -1551,12 +1852,14 @@ function openMarkerActionPanel(location) {
|
|||||||
renderRouteFocusPanel(null);
|
renderRouteFocusPanel(null);
|
||||||
renderMarkerActionPanel(location);
|
renderMarkerActionPanel(location);
|
||||||
elements.markerActionPanel.classList.remove("is-hidden");
|
elements.markerActionPanel.classList.remove("is-hidden");
|
||||||
|
syncMapOverlayState();
|
||||||
render();
|
render();
|
||||||
updateMarkerSelectionClasses();
|
updateMarkerSelectionClasses();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideMarkerActionPanel() {
|
function hideMarkerActionPanel() {
|
||||||
elements.markerActionPanel.classList.add("is-hidden");
|
elements.markerActionPanel.classList.add("is-hidden");
|
||||||
|
syncMapOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function closeMarkerActionPanel() {
|
function closeMarkerActionPanel() {
|
||||||
@@ -1689,6 +1992,7 @@ function setRouteFocusContent(eyebrow, title, meta) {
|
|||||||
elements.routeFocusPanel.classList.add("is-active");
|
elements.routeFocusPanel.classList.add("is-active");
|
||||||
elements.routeFocusTitle.textContent = title;
|
elements.routeFocusTitle.textContent = title;
|
||||||
elements.routeFocusMeta.textContent = meta;
|
elements.routeFocusMeta.textContent = meta;
|
||||||
|
syncMapOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideRouteFocusPanel() {
|
function hideRouteFocusPanel() {
|
||||||
@@ -1696,6 +2000,7 @@ function hideRouteFocusPanel() {
|
|||||||
setRouteFocusEyebrow("차량 정보");
|
setRouteFocusEyebrow("차량 정보");
|
||||||
elements.routeFocusTitle.textContent = "";
|
elements.routeFocusTitle.textContent = "";
|
||||||
elements.routeFocusMeta.textContent = "";
|
elements.routeFocusMeta.textContent = "";
|
||||||
|
syncMapOverlayState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCurrentLocationPanel(location) {
|
function renderCurrentLocationPanel(location) {
|
||||||
@@ -1726,6 +2031,20 @@ function renderRouteFocusPanel(location) {
|
|||||||
elements.routeFocusPanel.classList.add("is-active");
|
elements.routeFocusPanel.classList.add("is-active");
|
||||||
elements.routeFocusTitle.textContent = `${location.vehicleNo || location.driverId} · ${vehicleType.label}`;
|
elements.routeFocusTitle.textContent = `${location.vehicleNo || location.driverId} · ${vehicleType.label}`;
|
||||||
elements.routeFocusMeta.textContent = `${cargo} · ${destination} · ${progress}`;
|
elements.routeFocusMeta.textContent = `${cargo} · ${destination} · ${progress}`;
|
||||||
|
syncMapOverlayState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncMapOverlayState() {
|
||||||
|
if (!elements.mapPanel) return;
|
||||||
|
|
||||||
|
elements.mapPanel.classList.toggle(
|
||||||
|
"has-route-focus",
|
||||||
|
elements.routeFocusPanel.classList.contains("is-active")
|
||||||
|
);
|
||||||
|
elements.mapPanel.classList.toggle(
|
||||||
|
"has-marker-actions",
|
||||||
|
!elements.markerActionPanel.classList.contains("is-hidden")
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function focusLocation(location) {
|
function focusLocation(location) {
|
||||||
|
|||||||
@@ -205,6 +205,17 @@
|
|||||||
<section class="map-panel" aria-label="지도">
|
<section class="map-panel" aria-label="지도">
|
||||||
<div id="map"></div>
|
<div id="map"></div>
|
||||||
<div id="mapMessage" class="map-message">지도 로딩 중</div>
|
<div id="mapMessage" class="map-message">지도 로딩 중</div>
|
||||||
|
<div class="map-control-deck" aria-label="지도 조작">
|
||||||
|
<div class="map-layer-toggle" role="group" aria-label="지도 유형">
|
||||||
|
<button id="mapRoadButton" class="is-active" type="button" data-map-layer="road" aria-pressed="true">지도</button>
|
||||||
|
<button id="mapSkyButton" type="button" data-map-layer="sky" aria-pressed="false">스카이뷰</button>
|
||||||
|
</div>
|
||||||
|
<div class="map-zoom-stack" role="group" aria-label="확대 축소">
|
||||||
|
<button id="mapZoomInButton" type="button" data-map-zoom="in" aria-label="확대">+</button>
|
||||||
|
<button id="mapZoomOutButton" type="button" data-map-zoom="out" aria-label="축소">-</button>
|
||||||
|
</div>
|
||||||
|
<button id="mapExpandButton" class="map-expand-button" type="button" aria-pressed="false">지도 크게</button>
|
||||||
|
</div>
|
||||||
<section class="map-status-panel" aria-label="지도 운영 상태">
|
<section class="map-status-panel" aria-label="지도 운영 상태">
|
||||||
<span class="section-eyebrow">운행 지도</span>
|
<span class="section-eyebrow">운행 지도</span>
|
||||||
<strong id="mapStatusTitle">운행 현황</strong>
|
<strong id="mapStatusTitle">운행 현황</strong>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user