Skip to main content

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>