const ar = new URLSearchParams(location.search).get('lang') !== 'en'; const lang = ar ? 'ar' : 'en'; document.documentElement.lang = lang; document.documentElement.dir = ar ? 'rtl' : 'ltr'; const $ = (id) => document.getElementById(id); const requestOptions = () => typeof AbortSignal.timeout === 'function' ? {signal: AbortSignal.timeout(8000)} : {}; const english = { heading: 'Fleet map', register: 'Register your truck', filters: 'Filter trucks', intro: 'Explore trucks by the last city reported by the carrier. The map shows approved, available trucks updated within the past 24 hours.', country: 'Country', type: 'Truck type', all: 'All', payload: 'Required payload (tonnes)', height: 'Cargo height (m)', reset: 'Reset filters', overview: 'Regional overview', capacity: 'Results use carrier-reported capacity. Vehicle and route suitability are reviewed before booking.', imagery: 'Satellite imagery is temporarily unavailable. The regional map and city list remain available.', cities: 'Trucks by city', }; if (!ar) { for (const element of document.querySelectorAll('[data-label]')) { element.textContent = english[element.dataset.label]; } document.title = 'Fleet map | Phoenix Arabia'; $('home').href = '/shipping/en/'; const navigation = $('fleet-nav'); navigation.setAttribute('aria-label', 'Main navigation'); for (const [index,link] of [...navigation.querySelectorAll('a')].entries()) { link.href = ['/shipping/en/', '/shipping/en/services/', '/shipping/en/coverage/', '/fleet-map/?lang=en', '/portal/?view=request&lang=en', '/portal/?view=fleet&lang=en', '/portal/?view=account&lang=en', '/portal/?view=desk&lang=en'][index]; link.textContent = ['Home', 'Services', 'Coverage', 'Fleet map', 'Request freight', 'Register your truck', 'My account', 'Operations desk'][index]; } $('language').href = '?lang=ar'; $('language').lang = 'ar'; $('language').textContent = 'عربي'; } const local = (a, e) => ar ? a : e; const format = new Intl.NumberFormat(lang); const quoteLink = $('quote-from-map'); quoteLink.textContent = local('اطلب عرض شحن', 'Request a freight quote'); const canvas = $('map'); const ctx = canvas.getContext('2d'); const tileRoot = 'https://gibs.earthdata.nasa.gov/wmts/epsg3857/best/BlueMarble_ShadedRelief_Bathymetry/default/GoogleMapsCompatible_Level8/'; const tiles = new Map(); let geography = []; let framePending = false; let catalog, groups = [], visible = [], selected, unavailable = false, loaded = false; let width = 0, height = 0, zoom = 4, center; let markers = []; const clamp = (x, min, max) => Math.min(max, Math.max(min, x)); const size = () => 256 * 2 ** zoom; function project(lat, lon) { const mercator = Math.asinh(Math.tan(clamp(lat, -85, 85) * Math.PI / 180)); return [(lon + 180) / 360 * size(), (1 - mercator / Math.PI) / 2 * size()]; } function limitCenter() { const [minX, minY] = project(42, 23); const [maxX, maxY] = project(10, 65); center = [clamp(center[0], minX, maxX), clamp(center[1], minY, maxY)]; } function regional() { zoom = width < 420 ? 3 : width < 840 ? 4 : 5; center = project(27.5, 44.5); selected = undefined; updateQuoteLink(); draw(); } function setZoom(delta) { if (!center) return; const next = clamp(zoom + delta, 3, 8); center = center.map(value => value * 2 ** (next - zoom)); zoom = next; draw(); } function tile(z, row, column) { const key = z + '/' + row + '/' + column; if (tiles.has(key)) return tiles.get(key); if (tiles.size >= 180) tiles.delete(tiles.keys().next().value); const item = {image: new Image(), status: 'loading'}; tiles.set(key, item); item.image.referrerPolicy = 'no-referrer'; item.image.onload = () => { item.status = 'ready'; scheduleDraw(); }; item.image.onerror = () => { item.status = 'error'; scheduleDraw(); }; item.image.src = tileRoot + key + '.jpeg'; return item; } function scheduleDraw() { if (framePending) return; framePending = true; requestAnimationFrame(() => { framePending = false; draw(); }); } function drawGeography(left, top) { ctx.fillStyle = '#706c53'; ctx.strokeStyle = '#a7aa90'; ctx.lineWidth = 0.6; for (const {geometry} of geography) { const polygons = geometry.type === 'Polygon' ? [geometry.coordinates] : geometry.coordinates; for (const polygon of polygons) { ctx.beginPath(); for (const ring of polygon) { ring.forEach(([lon,lat], i) => { const [x,y] = project(lat,lon); if (i === 0) ctx.moveTo(x-left,y-top); else ctx.lineTo(x-left,y-top); }); ctx.closePath(); } ctx.fill('evenodd'); ctx.stroke(); } } } function draw() { if (!width || !height) return; ctx.fillStyle = '#182f47'; ctx.fillRect(0, 0, width, height); const left = center[0] - width / 2; const top = center[1] - height / 2; if (geography.length) drawGeography(left, top); let tileReady = false; let tileLoading = false; for (let row = Math.floor(top / 256); row <= Math.floor((top + height) / 256); row++) { for (let column = Math.floor(left / 256); column <= Math.floor((left + width) / 256); column++) { const limit = 2 ** zoom; if (row < 0 || column < 0 || row >= limit || column >= limit) continue; const item = tile(zoom, row, column); if (item.status === 'ready') { tileReady = true; ctx.drawImage(item.image, Math.round(column * 256 - left), Math.round(row * 256 - top), 256, 256); } if (item.status === 'loading') tileLoading = true; } } $('imagery-warning').hidden = tileReady || tileLoading; markers = []; const clusters = new Map(); for (const result of visible) { const [x, y] = project(result.city[3], result.city[4]); const dx = x - left, dy = y - top; if (dx < -16 || dy < -16 || dx > width + 16 || dy > height + 16) continue; const cell = Math.round(dx / 39) + ':' + Math.round(dy / 39); const cluster = clusters.get(cell) ?? {x: 0, y: 0, count: 0, items: []}; cluster.x += dx; cluster.y += dy; cluster.count += result.count; cluster.items.push(result); clusters.set(cell, cluster); } for (const cluster of clusters.values()) { const x = cluster.x / cluster.items.length, y = cluster.y / cluster.items.length; const active = cluster.items.some(item => item.code === selected); ctx.beginPath(); ctx.arc(x, y, 16, 0, 2 * Math.PI); ctx.fillStyle = active ? '#c6e886' : '#0d684c'; ctx.strokeStyle = '#fff'; ctx.lineWidth = 2; ctx.fill(); ctx.stroke(); ctx.fillStyle = active ? '#162739' : '#fff'; ctx.font = 'bold 11px system-ui'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(format.format(cluster.count), x, y, 28); markers.push({...cluster, x, y}); } } function focusCity(item) { selected = item.code; updateQuoteLink(); zoom = Math.max(zoom, 7); center = project(item.city[3], item.city[4]); draw(); } function updateQuoteLink() { const parameters = new URLSearchParams({view:'request',lang}); if (selected) parameters.set('city',selected); for (const [input,key] of [['type','type'],['payload','weight'],['height','height']]) { if ($(input).value && $(input).validity.valid) parameters.set(key,$(input).value); } quoteLink.href='/portal/?'+parameters; } function render() { const country = $('country').value, type = $('type').value; const requiredPayload = $('payload').valueAsNumber || 0, requiredHeight = $('height').valueAsNumber || 0; const totals = new Map(); for (const group of groups) { const city = catalog.cities[group.city]; if (!city || !catalog.types[group.type] || (country && country !== city[0]) || (type && type !== group.type) || Number(group.payload) < requiredPayload || Number(group.height) < requiredHeight) continue; const item = totals.get(group.city) ?? {code: group.city, city, count: 0, types: new Map()}; item.count += Number(group.total); item.types.set(group.type, (item.types.get(group.type) ?? 0) + Number(group.total)); totals.set(group.city, item); } visible = [...totals.values()].sort((a, b) => b.count - a.count || a.city[ar ? 1 : 2].localeCompare(b.city[ar ? 1 : 2], lang)); if (selected && !visible.some(item=>item.code===selected)) selected=undefined; updateQuoteLink(); const total = visible.reduce((sum, item) => sum + item.count, 0); $('count').textContent = unavailable ? local('بيانات الشاحنات غير متاحة مؤقتًا.', 'Fleet data is temporarily unavailable.') : !loaded ? local('جارٍ تحميل البيانات…', 'Loading fleet data…') : !total ? local('لا توجد شاحنات مطابقة للفلاتر.', 'No trucks match these filters.') : local('الشاحنات المطابقة: ', 'Matching trucks: ') + format.format(total); $('cities').replaceChildren(...visible.map(item => { const li = document.createElement('li'); const button = document.createElement('button'); button.type = 'button'; button.className = 'button secondary'; const name = document.createElement('span'); name.textContent = item.city[ar ? 1 : 2] + ' · ' + format.format(item.count); const typeSummary = document.createElement('span'); typeSummary.textContent = [...item.types].map(([type, count]) => catalog.types[type][ar ? 0 : 1] + ' ' + format.format(count)).join(' · '); button.append(name, typeSummary); button.addEventListener('click', () => focusCity(item)); li.append(button); return li; })); draw(); } function resize() { const bounds = canvas.getBoundingClientRect(); width = bounds.width; height = bounds.height; const density = Math.min(devicePixelRatio || 1, 2); canvas.width = Math.round(width * density); canvas.height = Math.round(height * density); ctx.setTransform(density, 0, 0, density, 0, 0); if (!center) regional(); else draw(); } let drag; canvas.addEventListener('pointerdown', event => { if (!center || !event.isPrimary || event.button !== 0) return; canvas.setPointerCapture(event.pointerId); drag = {x: event.clientX, y: event.clientY, center: [...center]}; }); canvas.addEventListener('pointermove', event => { if (!drag || !event.isPrimary) return; center = [drag.center[0] - event.clientX + drag.x, drag.center[1] - event.clientY + drag.y]; limitCenter(); scheduleDraw(); }); canvas.addEventListener('pointerup', event => { if (!drag || !event.isPrimary) return; if (Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 7) { const box = canvas.getBoundingClientRect(); const marker = markers.find(pin => Math.hypot(pin.x - event.clientX + box.left, pin.y - event.clientY + box.top) <= 17); if (marker?.items.length === 1) focusCity(marker.items[0]); else if (marker) { center = [center[0] + marker.x - width / 2, center[1] + marker.y - height / 2]; setZoom(1); } } drag = undefined; }); canvas.addEventListener('pointercancel', () => { drag = undefined; }); document.querySelectorAll('[data-zoom]').forEach(button => { button.setAttribute('aria-label', Number(button.dataset.zoom) > 0 ? local('تكبير', 'Zoom in') : local('تصغير', 'Zoom out')); button.addEventListener('click', () => setZoom(Number(button.dataset.zoom))); }); $('overview').addEventListener('click', regional); new ResizeObserver(resize).observe(canvas); const filterIds = ['country', 'type', 'payload', 'height']; for (const id of filterIds) $(id).addEventListener('input', render); $('reset').addEventListener('click', () => { for (const id of filterIds) $(id).value = ''; render(); regional(); }); async function refresh() { try { const response = await fetch('/fleet-map/data.php', requestOptions()); if (!response.ok) throw new Error('Unavailable'); const snapshot = (await response.json()).groups; if (!Array.isArray(snapshot)) throw new Error('Invalid fleet data'); groups = snapshot.filter(group => group && typeof group.city === 'string' && typeof group.type === 'string' && Number.isFinite(Number(group.payload)) && Number.isFinite(Number(group.height)) && Number.isSafeInteger(Number(group.total)) && Number(group.total) > 0); unavailable = false; } catch { groups = []; unavailable = true; } loaded = true; render(); } void fetch('/fleet-map/regional-geometry.json', requestOptions()) .then(response => response.ok ? response.json() : null) .then(data => { if (Array.isArray(data?.features)) { geography = data.features; scheduleDraw(); } }) .catch(() => {}); try { const response = await fetch('/portal/fleet-catalog.json', requestOptions()); if (!response.ok) throw new Error('Unavailable'); catalog = await response.json(); for (const [id, entries] of [['country', catalog.countries], ['type', catalog.types]]) { for (const [key, labels] of Object.entries(entries)) $(id).add(new Option(labels[ar ? 0 : 1], key)); } await refresh(); setInterval(() => { if (!document.hidden) void refresh(); }, 60000); } catch { unavailable = true; render(); }