Content & marketing
Globe code
Copy the implementation or inspect every file that belongs to this component unit.
resources/views/components/ui/globe.blade.php
@props([
'locations' => [],
'links' => [],
'traceHub' => null,
'traceVariant' => 'brand',
'traceSpeed' => 0.15,
'height' => 'lg',
'autoRotate' => true,
'rotateSpeed' => 0.22,
'locationSidebar' => true,
'layout' => 'overlay',
'sidebarTitle' => 'Locations',
'initialRotation' => [0, -18],
'textures' => [],
'surface' => 'terrain',
'clickableMarkers' => true,
])
@php
/*
| Interactive earth globe with location markers. Drag to spin; click a
| sidebar row or marker to focus that location. Auto-rotation pauses while
| dragging and honours prefers-reduced-motion.
|
| Each location needs `lat`, `lng` and `label`. Optional keys: `description`,
| `variant` (brand, red, amber, green, blue, gray), `type`.
|
| Marker, trace and atmosphere colours follow the active theme: the `brand`
| and `blue` variants track the theme's `brand`/`accent` tokens and repaint
| live when the theme switches.
|
| Draw great-circle links between locations with animated packet dots using
| `links` and/or `traceHub`. Links accept `from`/`to` index pairs, or a
| `through` array with `sequential` for multi-hop paths.
|
| `surface="terrain"` loads satellite-style textures (default). Use
| `surface="vector"` for a lightweight vector globe without texture
| downloads, or `surface="flat"` for a wide 2D equirectangular banner
| map with flush markers and animated routes.
|
| Usage:
| <x-ui.globe surface="vector" :locations="[...]" />
| <x-ui.globe surface="flat" height="banner" :location-sidebar="false" :locations="[...]" />
*/
$heightClasses = [
'sm' => 'h-72 sm:h-80',
'base' => 'h-80 sm:h-96',
'lg' => 'h-96 sm:h-[30rem]',
'xl' => 'h-[28rem] sm:h-[36rem]',
];
$bannerHeights = [
'sm' => 'h-56 sm:h-64',
'base' => 'h-64 sm:h-80',
'banner' => 'h-72 sm:h-[26rem]',
'lg' => 'h-80 sm:h-[28rem]',
'xl' => 'h-[26rem] sm:h-[32rem]',
];
$splitGlobeHeights = [
'sm' => 'h-64 sm:h-72',
'base' => 'h-72 sm:h-80',
'lg' => 'h-80 sm:h-96',
'xl' => 'h-96 sm:h-[28rem]',
];
$splitRowHeights = [
'sm' => 'lg:h-80',
'base' => 'lg:h-96',
'lg' => 'lg:h-[30rem]',
'xl' => 'lg:h-[36rem]',
];
$isSplit = $layout === 'split';
$locationItems = collect($locations)->values();
$showOverlaySidebar = $locationSidebar && ! $isSplit && ($locationItems->isNotEmpty() || isset($sidebar));
$showSplitSidebar = $isSplit && ($locationItems->isNotEmpty() || isset($sidebar));
$sidebarId = 'globe-location-sidebar-'.Str::random(8);
$globeLabel = trim((string) ($attributes->get('aria-label') ?: 'Interactive earth globe'));
$resolvedSurface = in_array($surface, ['terrain', 'vector', 'flat'], true) ? $surface : 'terrain';
$isFlat = $resolvedSurface === 'flat';
$showLoader = $resolvedSurface === 'terrain';
$config = [
'locations' => $locationItems->all(),
'links' => collect($links)->values()->all(),
'traceHub' => $traceHub !== null ? (int) $traceHub : null,
'traceVariant' => (string) $traceVariant,
'traceSpeed' => (float) $traceSpeed,
'autoRotate' => (bool) $autoRotate,
'rotateSpeed' => (float) $rotateSpeed,
'initialRotation' => collect($initialRotation)->take(2)->map(fn ($value) => (float) $value)->values()->all(),
'textures' => collect($textures)->only(['light', 'dark'])->filter()->all(),
'surface' => $resolvedSurface,
'clickableMarkers' => (bool) $clickableMarkers,
];
@endphp
<div
data-harbour-globe
data-globe-config='@json($config)'
@if ($showOverlaySidebar)
x-data="{ sidebarOpen: false }"
@keydown.escape.window="sidebarOpen = false"
@globe:location-selected="sidebarOpen = false"
@endif
{{ $attributes->except('aria-label')->class([
'harbour-globe overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900',
'flex flex-col lg:flex-row '.($splitRowHeights[$height] ?? $splitRowHeights['lg']) => $isSplit,
]) }}
>
<div
@class([
'relative flex items-center justify-center bg-gray-950',
($bannerHeights[$height] ?? $bannerHeights['banner']).' w-full' => $isFlat,
$heightClasses[$height] ?? $heightClasses['lg'] => ! $isFlat && ! $isSplit,
($splitGlobeHeights[$height] ?? $splitGlobeHeights['lg']).' lg:h-auto lg:w-2/3' => ! $isFlat && $isSplit,
])
>
<canvas
data-globe-canvas
role="img"
aria-label="{{ $globeLabel }}"
@class([
'relative z-10 touch-none',
'h-full w-full' => $isFlat,
'max-h-full max-w-full' => ! $isFlat,
'cursor-grab active:cursor-grabbing' => ! $clickableMarkers && ! $isFlat,
])
></canvas>
<div
data-globe-marker-popup
class="harbour-globe-marker-popup pointer-events-none absolute z-30 hidden max-w-[14rem] rounded-xl border border-gray-200 bg-white/95 p-3 shadow-lg backdrop-blur dark:border-gray-800 dark:bg-gray-900/95"
role="status"
aria-live="polite"
>
<p data-globe-marker-popup-label class="text-sm font-semibold text-gray-900 dark:text-white"></p>
<p data-globe-marker-popup-description class="mt-1 hidden text-xs leading-5 text-gray-500 dark:text-gray-400"></p>
<p data-globe-marker-popup-coordinates class="mt-1 font-mono text-[0.6875rem] uppercase tracking-wide text-gray-400 dark:text-gray-500"></p>
</div>
<div
data-globe-loading
role="status"
aria-live="polite"
@class([
'absolute inset-0 z-20 flex items-center justify-center bg-gray-950/70 text-sm font-medium text-gray-200 backdrop-blur-sm',
'hidden' => ! $showLoader,
])
>
<span class="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/10 px-3 py-1.5">
<x-ui.spinner size="sm" />
Loading globe
</span>
</div>
<div
data-globe-error
role="alert"
class="absolute inset-0 z-20 hidden items-center justify-center bg-gray-950/85 px-6 text-center text-sm text-gray-200 backdrop-blur-sm"
>
<div class="max-w-sm">
<div class="mx-auto flex h-10 w-10 items-center justify-center rounded-xl bg-red-500/10 text-red-300 ring-1 ring-red-500/20">
<x-ui.icon name="warning" size="xl" />
</div>
<p class="mt-3 text-sm font-semibold text-white">Globe unavailable</p>
<p class="mt-1 text-sm text-gray-400">
@if ($resolvedSurface === 'terrain')
The earth texture could not be loaded.
@else
The globe could not be initialised.
@endif
</p>
</div>
</div>
@if ($showOverlaySidebar)
<button
type="button"
data-globe-sidebar-toggle
x-show="! sidebarOpen"
x-cloak
class="absolute start-3 top-3 z-30 inline-flex items-center gap-2.5 rounded-xl border border-gray-200 bg-white/95 px-3 py-2 shadow-sm ring-1 ring-gray-900/5 transition hover:border-brand-300 dark:border-gray-800 dark:bg-gray-900/95 dark:ring-white/5 dark:hover:border-brand-800"
x-on:click="sidebarOpen = true"
:aria-expanded="sidebarOpen"
aria-controls="{{ $sidebarId }}"
>
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span class="absolute inset-0 rounded-full border border-brand-500/50"></span>
<span class="absolute inset-0.75 rounded-full bg-brand-500"></span>
</span>
<span class="text-xs font-semibold uppercase tracking-[0.14em] text-gray-900 dark:text-white">{{ $sidebarTitle }}</span>
<span class="font-mono text-[0.6875rem] tabular-nums text-gray-400 dark:text-gray-500">{{ sprintf('%02d', $locationItems->count()) }}</span>
</button>
<div
x-show="sidebarOpen"
x-cloak
x-transition.opacity
class="absolute inset-0 z-40 bg-gray-950/45 backdrop-blur-[1px]"
x-on:click="sidebarOpen = false"
aria-hidden="true"
></div>
<aside
id="{{ $sidebarId }}"
data-globe-sidebar
x-show="sidebarOpen"
x-cloak
x-transition:enter="transform transition ease-out duration-200"
x-transition:enter-start="translate-x-full"
x-transition:enter-end="translate-x-0"
x-transition:leave="transform transition ease-in duration-150"
x-transition:leave-start="translate-x-0"
x-transition:leave-end="translate-x-full"
class="absolute inset-y-0 end-0 z-50 flex w-72 max-w-[85%] flex-col border-s border-gray-200 bg-white/96 shadow-2xl backdrop-blur dark:border-gray-800 dark:bg-gray-900/96"
aria-label="{{ $sidebarTitle }}"
>
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<div class="flex min-w-0 items-center gap-2.5">
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span class="absolute inset-0 rounded-full border border-brand-500/50"></span>
<span class="absolute inset-0.75 rounded-full bg-brand-500"></span>
</span>
<div class="min-w-0">
<p class="truncate text-xs font-semibold uppercase tracking-[0.14em] text-gray-900 dark:text-white">{{ $sidebarTitle }}</p>
<p class="mt-0.5 font-mono text-[0.6875rem] uppercase tracking-wide text-gray-400 dark:text-gray-500">{{ sprintf('%02d', $locationItems->count()) }} {{ Str::plural('location', $locationItems->count()) }}</p>
</div>
</div>
<x-ui.icon-button icon="x" label="Close locations" size="sm" variant="ghost" x-on:click="sidebarOpen = false" />
</div>
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<x-ui.globe-location-list :locations="$locationItems->all()" />
</div>
</aside>
@endif
</div>
@if ($showSplitSidebar)
<aside
class="flex min-h-0 w-full flex-col border-t border-gray-200 bg-white lg:w-1/3 lg:border-s lg:border-t-0 dark:border-gray-800 dark:bg-gray-900"
aria-label="{{ $sidebarTitle }}"
>
<div class="flex items-center justify-between gap-3 border-b border-gray-200 px-4 py-3 dark:border-gray-800">
<div class="flex min-w-0 items-center gap-2.5">
<span class="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span class="absolute inset-0 rounded-full border border-brand-500/50"></span>
<span class="absolute inset-0.75 rounded-full bg-brand-500"></span>
</span>
<p class="truncate text-xs font-semibold uppercase tracking-[0.14em] text-gray-900 dark:text-white">{{ $sidebarTitle }}</p>
</div>
<p class="shrink-0 font-mono text-[0.6875rem] uppercase tracking-wide text-gray-400 dark:text-gray-500">{{ sprintf('%02d', $locationItems->count()) }} {{ Str::plural('location', $locationItems->count()) }}</p>
</div>
@isset($sidebar)
<div class="border-b border-gray-200 px-4 py-3 dark:border-gray-800">
{{ $sidebar }}
</div>
@endisset
<div class="min-h-0 flex-1 overflow-y-auto p-2">
<x-ui.globe-location-list :locations="$locationItems->all()" />
</div>
</aside>
@endif
</div>