Dark editorial landing page for a global media group: a draggable 3D dotted globe that turns to each bureau, kinetic condensed headlines, a divisions index, a live bureau map with local clocks, a campaign reach planner and a newsroom.
Set your access key from API keys as NEXTUI_TOKEN in .env.local and add the @nextui namespace below to components.json once. Then every item installs by name. Each install counts as a copy on a free account.
$pnpm dlx shadcn@latest add @nextui/global-media
1{2"registries": {3"@nextui": {4"url": "https://nextui.dev/r/{name}.json",5"headers": { "Authorization": "Bearer ${NEXTUI_TOKEN}" }6}7}8}
"use client";
import {
useEffect,
useMemo,
useRef,
useState,
useSyncExternalStore,
type FormEvent,
type KeyboardEvent as ReactKeyboardEvent,
type ReactNode,
} from "react";
import { AnimatePresence, MotionConfig, motion, useInView } from "motion/react";
import {
ArrowLeft,
ArrowRight,
ArrowUpRight,
Check,
Command,
CornerDownLeft,
Download,
Mail,
Play,
Radio,
Search,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { LAND_H, LAND_MASK, LAND_W } from "./world-land";
/* ─── Fonts & scoped styles ─────────────────────────────────────────────────── */
const EASE = [0.22, 1, 0.36, 1] as const;
function PageStyles() {
return (
<>
{/* The page is a self-contained registry item, so it brings its own fonts. */}
{/* eslint-disable-next-line @next/next/no-page-custom-font */}
<link
rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Antonio:wght@500;600;700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&family=Playfair+Display:ital,wght@0,600;0,700;1,600&display=swap"
precedence="default"
/>
<style
dangerouslySetInnerHTML={{
__html: `
.gm-root{--gm-bg:#0E0E12;--gm-ink:#F4F5FA;--gm-blue:#2F54FF;--gm-blue-soft:#8EA2FF;font-family:'Inter',ui-sans-serif,system-ui,sans-serif;background:var(--gm-bg);color:var(--gm-ink);-webkit-font-smoothing:antialiased}
.gm-display{font-family:'Antonio','Oswald','Bebas Neue',Impact,sans-serif;letter-spacing:-0.01em}
.gm-serif{font-family:'Playfair Display',Georgia,serif}
.gm-mono{font-family:'JetBrains Mono',ui-monospace,SFMono-Regular,monospace}
.gm-grain{background-image:url("data:image/svg+xml;utf8,<svg xmlns=%27http://www.w3.org/2000/svg%27 width=%27160%27 height=%27160%27><filter id=%27n%27><feTurbulence type=%27fractalNoise%27 baseFrequency=%270.9%27 numOctaves=%272%27 stitchTiles=%27stitch%27/></filter><rect width=%27100%25%27 height=%27100%25%27 filter=%27url(%23n)%27/></svg>")}
.gm-link{position:relative}
.gm-link::after{content:"";position:absolute;left:0;bottom:-3px;height:1px;width:100%;background:currentColor;transform:scaleX(0);transform-origin:right;transition:transform .45s cubic-bezier(.22,1,.36,1)}
.gm-link:hover::after,.gm-link:focus-visible::after{transform:scaleX(1);transform-origin:left}
@keyframes gm-marquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}
.gm-marquee{animation:gm-marquee 48s linear infinite}
.gm-marquee-fast{animation:gm-marquee 70s linear infinite}
.gm-marquee-host:hover .gm-marquee,.gm-marquee-host:hover .gm-marquee-fast{animation-play-state:paused}
@keyframes gm-dash{to{stroke-dashoffset:-24}}
.gm-arc{stroke-dasharray:3 3;animation:gm-dash 1.6s linear infinite}
@keyframes gm-ping{0%{transform:scale(1);opacity:.7}100%{transform:scale(3.2);opacity:0}}
.gm-ping{transform-box:fill-box;transform-origin:center;animation:gm-ping 2.2s cubic-bezier(0,0,.2,1) infinite}
@media (prefers-reduced-motion:reduce){.gm-marquee,.gm-marquee-fast,.gm-arc,.gm-ping{animation:none}}
`,
}}
/>
</>
);
}
/* ─── Data ──────────────────────────────────────────────────────────────────── */
const deg = (d: number) => (d * Math.PI) / 180;
interface Bureau {
id: string;
city: string;
country: string;
lat: number;
lon: number;
utc: number;
staff: number;
role: string;
langs: string;
beats: string[];
}
// 12 bureaus, 200 journalists in total: the numbers on the page all add up.
const BUREAUS: Bureau[] = [
{ id: "dxb", city: "Dubai", country: "UAE", lat: 25.2, lon: 55.27, utc: 4, staff: 64, role: "Headquarters & flagship studio", langs: "EN · AR", beats: ["Business", "Luxury", "Live events"] },
{ id: "ruh", city: "Riyadh", country: "Saudi Arabia", lat: 24.71, lon: 46.68, utc: 3, staff: 22, role: "Gulf policy & sport desk", langs: "EN · AR", beats: ["Policy", "Sport", "Giga-projects"] },
{ id: "cai", city: "Cairo", country: "Egypt", lat: 30.04, lon: 31.24, utc: 2, staff: 18, role: "Culture & Arabic video hub", langs: "AR · EN", beats: ["Film", "Music", "Culture"] },
{ id: "lon", city: "London", country: "United Kingdom", lat: 51.51, lon: -0.13, utc: 0, staff: 20, role: "European markets desk", langs: "EN", beats: ["Markets", "Fashion", "Tech"] },
{ id: "par", city: "Paris", country: "France", lat: 48.86, lon: 2.35, utc: 1, staff: 9, role: "Fashion & luxury houses", langs: "FR · EN", beats: ["Couture", "Beauty", "Design"] },
{ id: "bom", city: "Mumbai", country: "India", lat: 19.08, lon: 72.88, utc: 5.5, staff: 16, role: "South Asia startup desk", langs: "EN · HI", beats: ["Startups", "Cinema", "Retail"] },
{ id: "nbo", city: "Nairobi", country: "Kenya", lat: -1.29, lon: 36.82, utc: 3, staff: 8, role: "East Africa correspondents", langs: "EN · SW", beats: ["Fintech", "Climate"] },
{ id: "los", city: "Lagos", country: "Nigeria", lat: 6.52, lon: 3.38, utc: 1, staff: 9, role: "West Africa culture desk", langs: "EN", beats: ["Music", "Fintech"] },
{ id: "sin", city: "Singapore", country: "Singapore", lat: 1.35, lon: 103.82, utc: 8, staff: 11, role: "Asia-Pacific markets", langs: "EN · ZH", beats: ["Markets", "Travel"] },
{ id: "hkg", city: "Hong Kong", country: "China", lat: 22.32, lon: 114.17, utc: 8, staff: 7, role: "Luxury retail & finance", langs: "EN · ZH", beats: ["Retail", "Finance"] },
{ id: "nyc", city: "New York", country: "United States", lat: 40.71, lon: -74.0, utc: -5, staff: 12, role: "Americas markets & media", langs: "EN", beats: ["Media", "Markets"] },
{ id: "gru", city: "São Paulo", country: "Brazil", lat: -23.55, lon: -46.63, utc: -3, staff: 4, role: "LatAm sport & design", langs: "PT · EN", beats: ["Football", "Design"] },
];
const TOTAL_STAFF = BUREAUS.reduce((s, b) => s + b.staff, 0);
interface HeroSlide {
id: string;
eyebrow: string;
lines: [string, string, string, string];
side: [string, string];
stats: { value: number; decimals: number; suffix?: string; label: string }[];
focus: string; // bureau id the globe turns to
}
const HERO_SLIDES: HeroSlide[] = [
{
id: "01",
eyebrow: "Audience",
lines: ["LARGEST", "AUDIENCE.", "DEEPEST", "ENGAGEMENT."],
side: ["The region's largest independent media group", "Headquartered in Dubai since 1994"],
stats: [
{ value: 104.6, decimals: 1, suffix: "M", label: "monthly audience" },
{ value: 84, decimals: 0, label: "titles and channels" },
],
focus: "dxb",
},
{
id: "02",
eyebrow: "Newsroom",
lines: ["TWELVE", "BUREAUS.", "ONE", "NEWSROOM."],
side: ["Reporting from four continents", "around the clock, in five languages"],
stats: [
{ value: TOTAL_STAFF, decimals: 0, label: "journalists" },
{ value: BUREAUS.length, decimals: 0, label: "bureaus worldwide" },
],
focus: "lon",
},
{
id: "03",
eyebrow: "Advertising",
lines: ["STORIES", "THAT MOVE", "MARKETS", "& MINDS."],
side: ["Print, digital, video and live events", "sold as one audience"],
stats: [
{ value: 12.4, decimals: 1, suffix: "B", label: "annual impressions" },
{ value: 4.8, decimals: 1, suffix: "x", label: "dwell time vs. benchmark" },
],
focus: "bom",
},
];
interface Division {
code: string;
name: string;
short: string;
blurb: string;
titles: string[];
reach: number; // millions, monthly
growth: number; // % YoY
cpm: number; // USD
engagement: number; // % of impressions
mix: { print: number; web: number; social: number; video: number; events: number };
audience: { core: string; corePct: number; affluent: number };
}
const DIVISIONS: Division[] = [
{
code: "01",
name: "Luxury & Lifestyle",
short: "Luxury",
blurb: "Glossy monthlies and video franchises read by the region's highest-spending households.",
titles: ["Gilded", "Maison Arabia", "The Collector"],
reach: 38.4,
growth: 24.8,
cpm: 24,
engagement: 2.4,
mix: { print: 18, web: 28, social: 34, video: 14, events: 6 },
audience: { core: "25–44", corePct: 61, affluent: 48 },
},
{
code: "02",
name: "Culture & Entertainment",
short: "Culture",
blurb: "City guides, film and music coverage, and the weekend lists everyone forwards.",
titles: ["Nocturne", "Weekend City", "Marquee"],
reach: 44.2,
growth: 31.2,
cpm: 14,
engagement: 3.1,
mix: { print: 6, web: 34, social: 38, video: 18, events: 4 },
audience: { core: "18–34", corePct: 66, affluent: 29 },
},
{
code: "03",
name: "Business & Technology",
short: "Business",
blurb: "Market-moving reporting, founder interviews and the daily briefing for decision makers.",
titles: ["Signal Weekly", "Byte Desk", "Founders Gulf"],
reach: 22.0,
growth: 19.5,
cpm: 21,
engagement: 1.9,
mix: { print: 12, web: 46, social: 20, video: 14, events: 8 },
audience: { core: "30–54", corePct: 58, affluent: 54 },
},
{
code: "04",
name: "Travel & Hospitality",
short: "Travel",
blurb: "Destination journalism and trade titles for the people who book, build and run hotels.",
titles: ["Arcadia Travel", "Field & Frame", "Concierge"],
reach: 12.6,
growth: 14.1,
cpm: 17,
engagement: 2.2,
mix: { print: 16, web: 36, social: 30, video: 12, events: 6 },
audience: { core: "28–49", corePct: 57, affluent: 41 },
},
{
code: "05",
name: "Automotive & Sport",
short: "Sport",
blurb: "Road tests, race weekends and matchday coverage with a fiercely loyal audience.",
titles: ["Torque Arabia", "Matchday", "Paddock"],
reach: 9.8,
growth: 22.3,
cpm: 16,
engagement: 2.6,
mix: { print: 8, web: 30, social: 36, video: 22, events: 4 },
audience: { core: "21–44", corePct: 63, affluent: 33 },
},
{
code: "06",
name: "Events & Live",
short: "Events",
blurb: "Summits, award nights and streamed conversations that bring each title to life.",
titles: ["Meridian Summit", "The Gilded Awards", "Founders Night"],
reach: 3.4,
growth: 38.6,
cpm: 38,
engagement: 6.5,
mix: { print: 0, web: 18, social: 30, video: 22, events: 30 },
audience: { core: "30–54", corePct: 52, affluent: 62 },
},
];
const MIX_KEYS = ["print", "web", "social", "video", "events"] as const;
const MIX_LABEL: Record<(typeof MIX_KEYS)[number], string> = {
print: "Print",
web: "Web",
social: "Social",
video: "Video",
events: "Events",
};
const MIX_COLOR: Record<(typeof MIX_KEYS)[number], string> = {
print: "#E8EBF7",
web: "#2F54FF",
social: "#8EA2FF",
video: "#5B3FD9",
events: "#F2B544",
};
// Each title gets its own wordmark style so the strip reads like a newsstand.
const MASTHEADS: { name: string; style: string }[] = [
{ name: "Gilded", style: "gm-serif italic text-[30px] tracking-tight" },
{ name: "NOCTURNE", style: "gm-display text-[30px] tracking-[0.12em]" },
{ name: "Signal Weekly", style: "font-semibold text-[22px] tracking-[-0.04em]" },
{ name: "Maison Arabia", style: "gm-serif text-[24px] uppercase tracking-[0.18em]" },
{ name: "byte/desk", style: "gm-mono text-[21px] font-medium" },
{ name: "ARCADIA", style: "gm-serif text-[26px] font-bold tracking-[0.3em]" },
{ name: "Marquee", style: "gm-display text-[32px] font-bold uppercase" },
{ name: "Torque", style: "font-bold italic text-[26px] uppercase tracking-[-0.03em]" },
{ name: "The Collector", style: "gm-serif italic text-[24px]" },
{ name: "Weekend City", style: "font-medium text-[22px] uppercase tracking-[0.2em]" },
{ name: "MATCHDAY", style: "gm-display text-[30px] tracking-[0.04em]" },
{ name: "Founders Gulf", style: "gm-serif text-[24px] font-bold" },
];
const LIVE_DESK = [
{ time: "09:42", bureau: "Dubai", text: "Flagship studio goes live with a 4K morning markets show" },
{ time: "09:18", bureau: "London", text: "European tech funding rebounds for a third straight quarter" },
{ time: "08:55", bureau: "Paris", text: "Couture week opens with three regional designers on the schedule" },
{ time: "08:31", bureau: "Riyadh", text: "Season tickets sell out in under an hour for the new stadium" },
{ time: "08:07", bureau: "Mumbai", text: "Streaming deal brings two regional films to 190 countries" },
{ time: "07:46", bureau: "Lagos", text: "Afrobeats festival announces its first Gulf edition" },
];
type DeskId = "business" | "luxury" | "culture" | "travel" | "sport";
const DESKS: { id: DeskId | "all"; label: string }[] = [
{ id: "all", label: "All desks" },
{ id: "business", label: "Business" },
{ id: "luxury", label: "Luxury" },
{ id: "culture", label: "Culture" },
{ id: "travel", label: "Travel" },
{ id: "sport", label: "Sport" },
];
const DESK_LABEL: Record<DeskId, string> = {
business: "Business & Technology",
luxury: "Luxury & Lifestyle",
culture: "Culture & Entertainment",
travel: "Travel & Hospitality",
sport: "Automotive & Sport",
};
type ArtPattern = "dots" | "rings" | "lines" | "grid";
interface StoryArtSpec {
from: string;
to: string;
glyph: string;
accent: string;
pattern: ArtPattern;
}
interface Story {
id: string;
desk: DeskId;
bureau: string;
title: string;
dek: string;
author: string;
when: string;
read: number; // minutes
art: StoryArtSpec;
}
const STORIES: Story[] = [
{
id: "s1",
desk: "business",
bureau: "Dubai",
title: "Inside the desert studio rewriting how the region watches the news",
dek: "Twelve cameras, one control room and a newsroom that never closes. A week with the team behind the morning markets show.",
author: "Layla Haddad",
when: "2h ago",
read: 8,
art: { from: "#1B2BB8", to: "#0B0E2E", glyph: "DXB", accent: "#8EA2FF", pattern: "rings" },
},
{
id: "s2",
desk: "luxury",
bureau: "Paris",
title: "After hours in the ateliers dressing the next season",
dek: "Three regional designers, one borrowed workroom and eleven days to the runway.",
author: "Camille Roux",
when: "4h ago",
read: 6,
art: { from: "#3A1F8F", to: "#120A2E", glyph: "PAR", accent: "#F2B544", pattern: "lines" },
},
{
id: "s3",
desk: "culture",
bureau: "Lagos",
title: "The sound of Lagos is everywhere. Meet the people building its pipes",
dek: "Studios, distributors and the playlist editors who turned a city scene into a global export.",
author: "Tobi Adeyemi",
when: "5h ago",
read: 5,
art: { from: "#0F4C81", to: "#08162B", glyph: "LOS", accent: "#7FD3FF", pattern: "dots" },
},
{
id: "s4",
desk: "travel",
bureau: "Riyadh",
title: "The 72-hour route: how Riyadh became a weekend city",
dek: "Desert dinners, new museums and a late-night food scene. A long weekend, hour by hour.",
author: "Omar Al-Rashid",
when: "Yesterday",
read: 4,
art: { from: "#5B3FD9", to: "#16113A", glyph: "RUH", accent: "#E8EBF7", pattern: "grid" },
},
{
id: "s5",
desk: "business",
bureau: "Mumbai",
title: "The founders betting that the next big consumer app is built in Pune",
dek: "Cheaper talent, faster payments and a generation of operators who came home.",
author: "Ananya Iyer",
when: "Yesterday",
read: 7,
art: { from: "#0E5A6B", to: "#061A20", glyph: "BOM", accent: "#7FE0D0", pattern: "lines" },
},
{
id: "s6",
desk: "sport",
bureau: "São Paulo",
title: "Why Gulf clubs are scouting Brazilian teenagers before they turn pro",
dek: "Academies, agents and the data models deciding who gets a ticket out.",
author: "Rafael Costa",
when: "Yesterday",
read: 6,
art: { from: "#1F6F3F", to: "#08190F", glyph: "GRU", accent: "#B7F27A", pattern: "grid" },
},
{
id: "s7",
desk: "luxury",
bureau: "Hong Kong",
title: "Quiet money: the collectors buying watches they will never wear",
dek: "Inside the auction rooms where a steel sports watch now outsells a Monet sketch.",
author: "Grace Lam",
when: "2 days ago",
read: 9,
art: { from: "#6B2A1F", to: "#1E0B08", glyph: "HKG", accent: "#F2B544", pattern: "rings" },
},
{
id: "s8",
desk: "culture",
bureau: "Cairo",
title: "A century-old cinema reopens, and the queue goes round the block",
dek: "Restored seats, a new projector and a programme built from the city's own film archive.",
author: "Nour El-Sayed",
when: "2 days ago",
read: 5,
art: { from: "#7A3C8F", to: "#1C0E24", glyph: "CAI", accent: "#FFB4E1", pattern: "dots" },
},
{
id: "s9",
desk: "travel",
bureau: "Singapore",
title: "The airport that wants you to miss your connection",
dek: "Gardens, cinemas and a waterfall: how a layover became a destination.",
author: "Wei Ling Tan",
when: "3 days ago",
read: 4,
art: { from: "#1B5E8C", to: "#081A2A", glyph: "SIN", accent: "#9FE8FF", pattern: "rings" },
},
{
id: "s10",
desk: "sport",
bureau: "Riyadh",
title: "Sold out in 52 minutes: the season ticket rush, explained",
dek: "A new stadium, a new league format and fans who no longer fly abroad for big games.",
author: "Faisal Al-Otaibi",
when: "3 days ago",
read: 3,
art: { from: "#2F54FF", to: "#0B1240", glyph: "RUH", accent: "#E8EBF7", pattern: "lines" },
},
];
const MOST_READ = ["s7", "s1", "s5", "s10", "s3"];
const VIDEOS: { title: string; series: string; duration: string; art: StoryArtSpec }[] = [
{
title: "Twelve cameras, one control room: the 6am markets show",
series: "Behind the desk",
duration: "12:48",
art: { from: "#1B2BB8", to: "#070A22", glyph: "LIVE", accent: "#8EA2FF", pattern: "grid" },
},
{
title: "Couture week in four looks",
series: "Gilded in Paris",
duration: "06:15",
art: { from: "#3A1F8F", to: "#0E0822", glyph: "LOOK", accent: "#F2B544", pattern: "lines" },
},
{
title: "Matchday: 24 hours inside the new stadium",
series: "Matchday Films",
duration: "18:02",
art: { from: "#1F6F3F", to: "#06140B", glyph: "90'", accent: "#B7F27A", pattern: "rings" },
},
];
/* ─── Small helpers ─────────────────────────────────────────────────────────── */
function formatCompact(n: number) {
if (n >= 1e9) return `${(n / 1e9).toFixed(2)}B`;
if (n >= 1e6) return `${(n / 1e6).toFixed(1)}M`;
if (n >= 1e3) return `${(n / 1e3).toFixed(0)}K`;
return n.toFixed(0);
}
/** Tweens from the last shown value to the new one, so live numbers roll instead of jumping. */
function RollingNumber({
value,
format,
start = true,
duration = 900,
}: {
value: number;
format: (n: number) => string;
start?: boolean;
duration?: number;
}) {
const [display, setDisplay] = useState(0);
const fromRef = useRef(0);
useEffect(() => {
if (!start) return;
const from = fromRef.current;
const t0 = performance.now();
let raf = 0;
const tick = (now: number) => {
const p = Math.min((now - t0) / duration, 1);
const v = from + (value - from) * (1 - Math.pow(1 - p, 4));
fromRef.current = v;
setDisplay(v);
if (p < 1) raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, [value, start, duration]);
return <>{format(display)}</>;
}
/** Minute-resolution clock that renders nothing on the server, so local times never mismatch on hydrate. */
const clockStore = {
subscribe(cb: () => void) {
const id = window.setInterval(cb, 15_000);
return () => window.clearInterval(id);
},
snapshot() {
return Math.floor(Date.now() / 60_000);
},
server() {
return null;
},
};
function useMinute() {
return useSyncExternalStore(clockStore.subscribe, clockStore.snapshot, clockStore.server);
}
function localTime(minute: number | null, utc: number) {
if (minute === null) return { label: "--:--", hour: 12 };
const d = new Date(minute * 60_000 + utc * 3_600_000);
const h = d.getUTCHours();
const m = d.getUTCMinutes();
return { label: `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`, hour: h };
}
function SectionLabel({ index, children }: { index: string; children: string }) {
return (
<div className="gm-mono flex items-center gap-3 text-[11px] uppercase tracking-[0.22em] text-[var(--gm-blue-soft)]">
<span className="text-white/40">{index}</span>
<span className="h-px w-8 bg-white/20" />
{children}
</div>
);
}
function Reveal({ children, className, delay = 0 }: { children: ReactNode; className?: string; delay?: number }) {
return (
<motion.div
className={className}
initial={{ opacity: 0, y: 28 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true, margin: "-10% 0px" }}
transition={{ duration: 0.8, delay, ease: EASE }}
>
{children}
</motion.div>
);
}
/* ─── Hero globe ────────────────────────────────────────────────────────────── */
interface GlobeDot {
wx: number;
wy: number;
wz: number;
land: boolean;
}
/** Unit vector for a lat/lon, in the globe's own (unrotated) frame. */
function toWorld(lat: number, lon: number) {
const c = Math.cos(lat);
return { x: c * Math.sin(lon), y: Math.sin(lat), z: c * Math.cos(lon) };
}
/** Deterministic star positions, so the background never shuffles between renders. */
function buildStars(count: number) {
let seed = 7;
const rand = () => {
seed = (seed * 16807) % 2147483647;
return seed / 2147483647;
};
return Array.from({ length: count }, () => ({
x: rand(),
y: rand(),
size: rand() > 0.85 ? 1.6 : 1,
a: 0.25 + rand() * 0.55,
speed: 0.6 + rand() * 1.6,
phase: rand() * Math.PI * 2,
}));
}
const GLOBE_SAMPLES = 32000;
const LIGHT = (() => {
const v = [0.78, 0.42, 0.28];
const len = Math.hypot(v[0], v[1], v[2]);
return [v[0] / len, v[1] / len, v[2] / len];
})();
function decodeLandMask() {
return Uint8Array.from(atob(LAND_MASK), (ch) => ch.charCodeAt(0));
}
function isLand(mask: Uint8Array, row: number, col: number) {
const idx = row * LAND_W + col;
return (mask[idx >> 3] & (1 << (idx & 7))) !== 0;
}
function buildGlobeDots(): GlobeDot[] {
const mask = decodeLandMask();
const golden = Math.PI * (3 - Math.sqrt(5));
const dots: GlobeDot[] = [];
for (let i = 0; i < GLOBE_SAMPLES; i++) {
const lat = Math.asin(1 - (2 * (i + 0.5)) / GLOBE_SAMPLES);
const lon = ((i * golden) % (Math.PI * 2)) - Math.PI;
const row = Math.min(LAND_H - 1, Math.max(0, Math.floor(((Math.PI / 2 - lat) / Math.PI) * LAND_H)));
const col = Math.min(LAND_W - 1, Math.floor(((((lon + Math.PI) / (Math.PI * 2)) % 1) + 1) % 1 * LAND_W));
const land = isLand(mask, row, col);
if (land || i % 9 === 0) {
const w = toWorld(lat, lon);
dots.push({ wx: w.x, wy: w.y, wz: w.z, land });
}
}
return dots;
}
function MediaGlobe({ focus }: { focus: Bureau }) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const focusRef = useRef(focus);
const rotRef = useRef({ lon: -deg(focus.lon) + 0.3, lat: deg(focus.lat) - 0.12, spin: 0 });
const dragRef = useRef({ active: false, lastX: 0, lastY: 0, offLon: 0, offLat: 0 });
useEffect(() => {
focusRef.current = focus;
rotRef.current.spin = 0;
// Let the slide take the globe back from a drag.
dragRef.current.offLon *= 0.2;
dragRef.current.offLat = 0;
}, [focus]);
useEffect(() => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext("2d");
if (!canvas || !ctx) return;
const dots = buildGlobeDots();
const stars = buildStars(160);
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
let raf = 0;
let time = 0;
let labelAlpha = 0;
let lastFocus = focusRef.current.id;
// Size is read by a ResizeObserver, not getBoundingClientRect() every frame (which forced a layout).
let W = 0;
let H = 0;
// The loop only runs while the canvas is on screen and the tab is visible.
let onScreen = false;
// Dots are drawn in batches that share a colour: one fill per bucket instead of a
// fillStyle string per dot (about 12k a frame), which kept the CPU pinned and the GC busy.
const LIGHT_STEPS = 12;
const TINT_STEPS = 4;
const landFill: string[] = [];
for (let l = 0; l < LIGHT_STEPS; l++) {
for (let t = 0; t < TINT_STEPS; t++) {
const light = (l + 0.5) / LIGHT_STEPS;
const tint = (t + 0.5) / TINT_STEPS;
const r = Math.min(255, Math.round(110 + light * 120 - tint * 40));
const g = Math.min(255, Math.round(104 + light * 130 + tint * 40));
landFill.push(`rgba(${r}, ${g}, 255, ${Math.min(1, 0.5 + light * 0.5).toFixed(3)})`);
}
}
const oceanFill = Array.from({ length: LIGHT_STEPS }, (_, l) => `rgba(120, 112, 255, ${(0.05 + ((l + 0.5) / LIGHT_STEPS) * 0.14).toFixed(3)})`);
const project = (lat: number, lon: number, lon0: number, cosLat0: number, sinLat0: number) => {
const cosPhi = Math.cos(lat);
const sinPhi = Math.sin(lat);
const theta = lon + lon0;
const x = cosPhi * Math.sin(theta);
const ct = cosPhi * Math.cos(theta);
return { x, y: sinPhi * cosLat0 - ct * sinLat0, z: sinPhi * sinLat0 + ct * cosLat0 };
};
const render = () => {
raf = 0;
if (!onScreen || document.hidden) return;
time += 0.016;
const dpr = Math.min(window.devicePixelRatio || 1, 2);
if (W === 0 || H === 0) {
raf = requestAnimationFrame(render);
return;
}
const bw = Math.round(W * dpr);
const bh = Math.round(H * dpr);
if (canvas.width !== bw || canvas.height !== bh) {
canvas.width = bw;
canvas.height = bh;
}
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, W, H);
const cx = W / 2;
const cy = H / 2;
const R = Math.min(W, H) * 0.34; // leaves room for the orbit rings
const f = focusRef.current;
if (f.id !== lastFocus) {
lastFocus = f.id;
labelAlpha = 0;
}
const target = { lon: -deg(f.lon) + 0.3, lat: deg(f.lat) - 0.12 };
const rot = rotRef.current;
if (!dragRef.current.active) {
if (!reduceMotion) rot.spin += 0.00025;
rot.lon += (target.lon + rot.spin - rot.lon) * 0.035;
rot.lat += (target.lat - rot.lat) * 0.035;
}
const lon0 = rot.lon + dragRef.current.offLon;
const lat0 = Math.max(-0.6, Math.min(0.7, rot.lat + dragRef.current.offLat));
const cosLat0 = Math.cos(lat0);
const sinLat0 = Math.sin(lat0);
// Rotate a world-space vector into view space (same transform as `project`).
const cosL = Math.cos(lon0);
const sinL = Math.sin(lon0);
const view = (x: number, y: number, z: number) => {
const x1 = x * cosL + z * sinL;
const z1 = -x * sinL + z * cosL;
return { x: x1, y: y * cosLat0 - z1 * sinLat0, z: y * sinLat0 + z1 * cosLat0 };
};
const fv = toWorld(deg(f.lat), deg(f.lon));
// Starfield: faint, twinkling, and only outside the planet.
for (let i = 0; i < stars.length; i++) {
const s = stars[i];
const sx = s.x * W;
const sy = s.y * H;
if (Math.hypot(sx - cx, sy - cy) < R * 1.05) continue;
const tw = reduceMotion ? 0.6 : 0.45 + 0.55 * Math.abs(Math.sin(time * s.speed + s.phase));
ctx.fillStyle = `rgba(200, 210, 255, ${s.a * tw})`;
ctx.fillRect(sx, sy, s.size, s.size);
}
// Orbit rings: the far half is drawn behind the planet, the near half in front.
const orbits = [
{ rx: R * 1.36, ry: R * 0.3, rot: -0.32, speed: 0.22, color: "142, 162, 255" },
{ rx: R * 1.2, ry: R * 0.5, rot: 0.48, speed: -0.15, color: "242, 181, 68" },
];
const drawOrbit = (o: (typeof orbits)[number], front: boolean) => {
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(o.rot);
const grad = ctx.createLinearGradient(-o.rx, 0, o.rx, 0);
grad.addColorStop(0, `rgba(${o.color}, 0)`);
grad.addColorStop(0.5, `rgba(${o.color}, ${front ? 0.55 : 0.18})`);
grad.addColorStop(1, `rgba(${o.color}, 0)`);
ctx.strokeStyle = grad;
ctx.lineWidth = front ? 1.1 : 0.8;
ctx.beginPath();
ctx.ellipse(0, 0, o.rx, o.ry, 0, front ? 0 : Math.PI, front ? Math.PI : Math.PI * 2);
ctx.stroke();
// A satellite riding the ring.
const t = (reduceMotion ? 1 : time * o.speed) % (Math.PI * 2);
const inFront = Math.sin(t) > 0;
if (inFront === front) {
const sx = Math.cos(t) * o.rx;
const sy = Math.sin(t) * o.ry;
const glow = ctx.createRadialGradient(sx, sy, 0, sx, sy, 10);
glow.addColorStop(0, `rgba(${o.color}, ${front ? 0.9 : 0.35})`);
glow.addColorStop(1, `rgba(${o.color}, 0)`);
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(sx, sy, 10, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = front ? "#FFFFFF" : `rgba(${o.color}, 0.6)`;
ctx.beginPath();
ctx.arc(sx, sy, front ? 2 : 1.4, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
};
for (const o of orbits) drawOrbit(o, false);
// Atmosphere: a wide soft halo plus a tight rim glow.
const atmo = ctx.createRadialGradient(cx + R * 0.25, cy - R * 0.2, R * 0.7, cx, cy, R * 1.45);
atmo.addColorStop(0, "rgba(96, 84, 255, 0.30)");
atmo.addColorStop(0.55, "rgba(70, 60, 220, 0.12)");
atmo.addColorStop(1, "rgba(14, 14, 18, 0)");
ctx.fillStyle = atmo;
ctx.beginPath();
ctx.arc(cx, cy, R * 1.45, 0, Math.PI * 2);
ctx.fill();
const rimGlow = ctx.createRadialGradient(cx, cy, R * 0.94, cx, cy, R * 1.08);
rimGlow.addColorStop(0, "rgba(120, 140, 255, 0)");
rimGlow.addColorStop(0.5, "rgba(120, 140, 255, 0.28)");
rimGlow.addColorStop(1, "rgba(120, 140, 255, 0)");
ctx.fillStyle = rimGlow;
ctx.beginPath();
ctx.arc(cx, cy, R * 1.08, 0, Math.PI * 2);
ctx.fill();
// Sphere body.
const body = ctx.createRadialGradient(cx + R * 0.45, cy - R * 0.35, R * 0.05, cx, cy, R);
body.addColorStop(0, "#2E26A0");
body.addColorStop(0.4, "#1C1666");
body.addColorStop(0.8, "#110D3F");
body.addColorStop(1, "#0A0828");
ctx.fillStyle = body;
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.fill();
// Land and ocean dots: Lambert shading, a cyan-to-violet tint across the disc,
// and a soft spotlight around the bureau the slide is about. Dots outside the
// spotlight go into colour buckets (one path each); spotlit ones are drawn exactly.
const landPaths: (Path2D | undefined)[] = [];
const oceanPaths: (Path2D | undefined)[] = [];
const spotlit: { x: number; y: number; s: number; fill: string }[] = [];
for (let i = 0; i < dots.length; i++) {
const d = dots[i];
const p = view(d.wx, d.wy, d.wz);
if (p.z < 0.02) continue;
const lambert = Math.max(0, p.x * LIGHT[0] + p.y * LIGHT[1] + p.z * LIGHT[2]);
const rim = Math.pow(1 - p.z, 2.2);
const light = Math.min(1, lambert * lambert * 1.15 + rim * lambert * 1.6);
const near = d.wx * fv.x + d.wy * fv.y + d.wz * fv.z; // cos of angular distance to focus
const spot = near > 0.9 ? Math.pow((near - 0.9) / 0.1, 1.6) : 0;
if (spot === 0) {
const l = Math.min(LIGHT_STEPS - 1, Math.floor(light * LIGHT_STEPS));
if (d.land) {
const t = Math.min(TINT_STEPS - 1, Math.floor(((p.x + 1) / 2) * TINT_STEPS));
const k = l * TINT_STEPS + t;
const s = 1.6 * (0.5 + p.z * 0.5);
(landPaths[k] ??= new Path2D()).rect(cx + p.x * R - s / 2, cy - p.y * R - s / 2, s, s);
} else {
const s = 0.5 + p.z * 0.5;
(oceanPaths[l] ??= new Path2D()).rect(cx + p.x * R - s / 2, cy - p.y * R - s / 2, s, s);
}
continue;
}
if (d.land) {
const tint = (p.x + 1) / 2; // 0 left (violet) .. 1 right (cyan)
const r = 110 + light * 120 - tint * 40 + spot * 90;
const g = 104 + light * 130 + tint * 40 + spot * 70;
const b = 255;
const s = (1.6 + spot * 0.9) * (0.5 + p.z * 0.5);
const fill = `rgba(${Math.min(255, Math.round(r))}, ${Math.min(255, Math.round(g))}, ${b}, ${Math.min(1, 0.5 + light * 0.5 + spot * 0.4)})`;
spotlit.push({ x: cx + p.x * R - s / 2, y: cy - p.y * R - s / 2, s, fill });
} else {
const s = 0.5 + p.z * 0.5;
const fill = `rgba(120, 112, 255, ${0.05 + light * 0.14 + spot * 0.2})`;
spotlit.push({ x: cx + p.x * R - s / 2, y: cy - p.y * R - s / 2, s, fill });
}
}
for (let k = 0; k < oceanPaths.length; k++) {
const path = oceanPaths[k];
if (!path) continue;
ctx.fillStyle = oceanFill[k];
ctx.fill(path);
}
for (let k = 0; k < landPaths.length; k++) {
const path = landPaths[k];
if (!path) continue;
ctx.fillStyle = landFill[k];
ctx.fill(path);
}
// The spotlight around the focused bureau goes on top, with exact colours.
for (const d of spotlit) {
ctx.fillStyle = d.fill;
ctx.fillRect(d.x, d.y, d.s, d.s);
}
// Story routes: arcs from HQ out to every bureau, lifted off the surface,
// with a bright packet travelling along each one.
const hq = toWorld(deg(BUREAUS[0].lat), deg(BUREAUS[0].lon));
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.lineCap = "round";
for (let k = 1; k < BUREAUS.length; k++) {
const b = BUREAUS[k];
const tv = toWorld(deg(b.lat), deg(b.lon));
const omega = Math.acos(Math.max(-1, Math.min(1, hq.x * tv.x + hq.y * tv.y + hq.z * tv.z)));
if (omega < 0.01) continue;
const sinO = Math.sin(omega);
const lift = 0.06 + (omega / Math.PI) * 0.32;
const steps = 48;
const pts: { x: number; y: number; vis: boolean }[] = [];
for (let s = 0; s <= steps; s++) {
const t = s / steps;
const a = Math.sin((1 - t) * omega) / sinO;
const c = Math.sin(t * omega) / sinO;
const h = 1 + lift * Math.sin(Math.PI * t);
const p = view((hq.x * a + tv.x * c) * h, (hq.y * a + tv.y * c) * h, (hq.z * a + tv.z * c) * h);
const vis = p.z > 0 || p.x * p.x + p.y * p.y > 1;
pts.push({ x: cx + p.x * R, y: cy - p.y * R, vis });
}
const active = b.id === f.id;
ctx.strokeStyle = active ? "rgba(255, 255, 255, 0.75)" : "rgba(110, 140, 255, 0.32)";
ctx.lineWidth = active ? 1.4 : 0.9;
ctx.beginPath();
let pen = false;
for (const pt of pts) {
if (!pt.vis) {
pen = false;
continue;
}
if (pen) ctx.lineTo(pt.x, pt.y);
else ctx.moveTo(pt.x, pt.y);
pen = true;
}
ctx.stroke();
// Travelling packet with a short tail.
const head = reduceMotion ? 0.6 : (time * 0.28 + k * 0.137) % 1;
const hi = Math.round(head * steps);
for (let tail = 0; tail < 8; tail++) {
const pt = pts[hi - tail];
if (!pt || !pt.vis) break;
const alpha = (1 - tail / 8) * (active ? 1 : 0.8);
ctx.fillStyle = active ? `rgba(255, 255, 255, ${alpha})` : `rgba(160, 190, 255, ${alpha})`;
ctx.beginPath();
ctx.arc(pt.x, pt.y, (active ? 2.2 : 1.6) * (1 - tail / 10), 0, Math.PI * 2);
ctx.fill();
}
}
ctx.restore();
// Bureau lights.
let focusPoint: { x: number; y: number } | null = null;
ctx.save();
ctx.globalCompositeOperation = "lighter";
for (let i = 0; i < BUREAUS.length; i++) {
const b = BUREAUS[i];
const p = project(deg(b.lat), deg(b.lon), lon0, cosLat0, sinLat0);
if (p.z < 0.12) continue;
const px = cx + p.x * R;
const py = cy - p.y * R;
const pulse = reduceMotion ? 0.5 : (Math.sin(time * 2.4 + i * 1.7) + 1) / 2;
const glow = ctx.createRadialGradient(px, py, 0, px, py, 12 + pulse * 6);
glow.addColorStop(0, `rgba(170, 190, 255, ${0.35 + pulse * 0.2})`);
glow.addColorStop(1, "rgba(170, 190, 255, 0)");
ctx.fillStyle = glow;
ctx.beginPath();
ctx.arc(px, py, 12 + pulse * 6, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#FFFFFF";
ctx.beginPath();
ctx.arc(px, py, i === 0 ? 2.4 : 1.8, 0, Math.PI * 2);
ctx.fill();
if (b.id === f.id && p.z > 0.3) focusPoint = { x: px, y: py };
}
ctx.restore();
// Specular crescent and night side.
ctx.save();
ctx.beginPath();
ctx.arc(cx, cy, R, 0, Math.PI * 2);
ctx.clip();
const crescent = ctx.createRadialGradient(cx - R * 0.18, cy + R * 0.12, R * 0.9, cx - R * 0.18, cy + R * 0.12, R * 1.18);
crescent.addColorStop(0, "rgba(255, 255, 255, 0)");
crescent.addColorStop(0.7, "rgba(214, 220, 255, 0.28)");
crescent.addColorStop(1, "rgba(255, 255, 255, 0.75)");
ctx.fillStyle = crescent;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
const night = ctx.createLinearGradient(cx + R * 0.6, cy - R * 0.6, cx - R, cy + R);
night.addColorStop(0, "rgba(8, 6, 30, 0)");
night.addColorStop(0.55, "rgba(8, 6, 30, 0.05)");
night.addColorStop(1, "rgba(8, 6, 30, 0.6)");
ctx.fillStyle = night;
ctx.fillRect(cx - R, cy - R, R * 2, R * 2);
ctx.restore();
ctx.save();
ctx.shadowColor = "rgba(190, 200, 255, 0.9)";
ctx.shadowBlur = 18;
ctx.strokeStyle = "rgba(255, 255, 255, 0.85)";
ctx.lineWidth = 1.6;
ctx.lineCap = "round";
ctx.beginPath();
ctx.arc(cx, cy, R - 0.5, -Math.PI * 0.46, Math.PI * 0.2);
ctx.stroke();
ctx.restore();
for (const o of orbits) drawOrbit(o, true);
// Focused bureau: ring, leader line and a mono label that fades in once the globe settles.
// Small screens put the headline over the globe, so the label only shows on wide canvases.
if (focusPoint && W >= 640) {
labelAlpha = Math.min(1, labelAlpha + 0.03);
const { x, y } = focusPoint;
const a = labelAlpha;
const ring = reduceMotion ? 0.5 : (time * 0.7) % 1;
ctx.strokeStyle = `rgba(255, 255, 255, ${a * (1 - ring) * 0.8})`;
ctx.lineWidth = 1;
ctx.beginPath();
ctx.arc(x, y, 6 + ring * 18, 0, Math.PI * 2);
ctx.stroke();
ctx.strokeStyle = `rgba(255, 255, 255, ${a * 0.9})`;
ctx.beginPath();
ctx.arc(x, y, 6, 0, Math.PI * 2);
ctx.stroke();
const lx = x + 34;
const ly = y - 34;
ctx.beginPath();
ctx.moveTo(x + 4.5, y - 4.5);
ctx.lineTo(lx, ly);
ctx.lineTo(lx + 14, ly);
ctx.stroke();
ctx.font = "500 11px 'JetBrains Mono', ui-monospace, monospace";
ctx.fillStyle = `rgba(255, 255, 255, ${a})`;
ctx.fillText(f.city.toUpperCase(), lx + 20, ly + 4);
ctx.fillStyle = `rgba(174, 186, 255, ${a * 0.85})`;
ctx.fillText(`${f.staff} journalists`, lx + 20, ly + 19);
}
raf = requestAnimationFrame(render);
};
const start = () => {
if (onScreen && !document.hidden && !raf) raf = requestAnimationFrame(render);
};
const ro = new ResizeObserver(([entry]) => {
W = entry.contentRect.width;
H = entry.contentRect.height;
});
ro.observe(canvas);
const io = new IntersectionObserver(([entry]) => {
onScreen = entry.isIntersecting;
start();
});
io.observe(canvas);
document.addEventListener("visibilitychange", start);
return () => {
cancelAnimationFrame(raf);
ro.disconnect();
io.disconnect();
document.removeEventListener("visibilitychange", start);
};
}, []);
return (
<canvas
ref={canvasRef}
aria-label={`Globe turned to the ${focus.city} bureau. Drag to rotate.`}
role="img"
onPointerDown={(e) => {
dragRef.current.active = true;
dragRef.current.lastX = e.clientX;
dragRef.current.lastY = e.clientY;
e.currentTarget.setPointerCapture(e.pointerId);
}}
onPointerMove={(e) => {
if (!dragRef.current.active) return;
const dx = e.clientX - dragRef.current.lastX;
const dy = e.clientY - dragRef.current.lastY;
dragRef.current.lastX = e.clientX;
dragRef.current.lastY = e.clientY;
dragRef.current.offLon += dx * 0.005;
dragRef.current.offLat = Math.max(-0.45, Math.min(0.45, dragRef.current.offLat + dy * 0.004));
}}
onPointerUp={() => {
dragRef.current.active = false;
}}
onPointerCancel={() => {
dragRef.current.active = false;
}}
className="size-full cursor-grab touch-none select-none active:cursor-grabbing"
/>
);
}
/* ─── Header, menu and hero ─────────────────────────────────────────────────── */
const NAV = [
{ label: "Divisions", href: "#divisions" },
{ label: "Bureaus", href: "#bureaus" },
{ label: "Advertise", href: "#advertise" },
{ label: "Newsroom", href: "#newsroom" },
];
function Wordmark({ small = false }: { small?: boolean }) {
return (
<span className="flex select-none items-center gap-3">
<span className={cn("gm-serif font-bold leading-none tracking-tight text-white", small ? "text-[22px]" : "text-[28px]")}>
Meridian
</span>
<span className={cn("w-px bg-white/30", small ? "h-5" : "h-7")} />
<span className="flex flex-col text-[10.5px] font-medium uppercase leading-[1.25] tracking-[0.16em] text-white/70">
<span>Media</span>
<span>Group</span>
</span>
</span>
);
}
const SLIDE_MS = 9000;
function Hero({ onOpenSearch, menuOpen, setMenuOpen }: { onOpenSearch: () => void; menuOpen: boolean; setMenuOpen: (v: boolean) => void }) {
const [slideIdx, setSlideIdx] = useState(0);
const [paused, setPaused] = useState(false);
const slide = HERO_SLIDES[slideIdx];
const focus = BUREAUS.find((b) => b.id === slide.focus) ?? BUREAUS[0];
useEffect(() => {
if (paused) return;
const id = window.setTimeout(() => setSlideIdx((i) => (i + 1) % HERO_SLIDES.length), SLIDE_MS);
return () => window.clearTimeout(id);
}, [slideIdx, paused]);
const go = (dir: 1 | -1) => setSlideIdx((i) => (i + dir + HERO_SLIDES.length) % HERO_SLIDES.length);
return (
<section
id="top"
aria-roledescription="carousel"
aria-label="Meridian Media Group highlights"
className="relative isolate flex min-h-[max(100svh,720px)] w-full flex-col overflow-hidden bg-[var(--gm-bg)]"
>
<div className="absolute left-1/2 top-[46%] z-0 aspect-square w-[158vw] -translate-x-1/2 -translate-y-1/2 sm:w-[112vw] lg:left-[60%] lg:top-[50%] lg:w-[min(76vw,132vh)] lg:max-w-[1260px]">
<MediaGlobe focus={focus} />
</div>
<div aria-hidden className="pointer-events-none absolute inset-0 z-[1] bg-[linear-gradient(90deg,var(--gm-bg)_0%,rgba(14,14,18,0.78)_28%,rgba(14,14,18,0)_55%)]" />
<div aria-hidden className="pointer-events-none absolute inset-0 z-[1] bg-[radial-gradient(120%_90%_at_60%_45%,transparent_55%,rgba(8,8,11,0.9)_100%)]" />
<div aria-hidden className="pointer-events-none absolute inset-x-0 bottom-0 z-[1] h-48 bg-gradient-to-t from-[var(--gm-bg)] to-transparent" />
<div aria-hidden className="gm-grain pointer-events-none absolute inset-0 z-[2] opacity-[0.07] mix-blend-overlay" />
{/* Header */}
<header className="relative z-40 flex w-full items-center justify-between gap-6 px-6 pt-7 sm:px-12 sm:pt-9 lg:px-16">
<a href="#top" aria-label="Meridian Media Group home">
<Wordmark />
</a>
<nav aria-label="Primary" className="hidden items-center gap-9 text-[13px] text-white/75 lg:flex">
{NAV.map((item) => (
<a key={item.href} href={item.href} className="gm-link transition-colors hover:text-white">
{item.label}
</a>
))}
</nav>
<div className="flex items-center gap-2 sm:gap-3">
<button
type="button"
onClick={onOpenSearch}
className="group inline-flex h-9 items-center gap-2.5 rounded-full border border-white/15 bg-white/[0.04] px-3 text-white/70 backdrop-blur-md transition-colors hover:border-white/30 hover:text-white"
>
<Search className="size-4" />
<span className="sr-only sm:not-sr-only sm:text-[12px]">Search</span>
<kbd className="gm-mono hidden rounded border border-white/15 px-1.5 py-px text-[10px] text-white/50 sm:inline">⌘K</kbd>
</button>
<a
href="#advertise"
className="hidden h-9 items-center gap-2 rounded-full bg-white px-4 text-[12px] font-semibold text-[#0E0E12] transition-colors hover:bg-[#DCE2FF] md:inline-flex"
>
Advertise with us
<ArrowUpRight className="size-3.5" />
</a>
<button
type="button"
onClick={() => setMenuOpen(!menuOpen)}
aria-label={menuOpen ? "Close menu" : "Open menu"}
aria-expanded={menuOpen}
className="group relative z-50 flex h-9 w-10 flex-col items-end justify-center gap-[7px] text-white lg:hidden"
>
<span className={cn("h-[1.5px] w-7 bg-white transition-all duration-300", menuOpen && "translate-y-[4.25px] rotate-45")} />
<span className={cn("h-[1.5px] bg-white transition-all duration-300", menuOpen ? "w-7 -translate-y-[4.25px] -rotate-45" : "w-4 group-hover:w-7")} />
</button>
</div>
</header>
{/* Stage */}
<div
className="relative z-20 flex flex-1 items-center px-6 sm:px-12 lg:px-16"
onPointerEnter={() => setPaused(true)}
onPointerLeave={() => setPaused(false)}
>
<div className="w-full py-10 lg:w-[48%]">
<div key={`eyebrow-${slide.id}`} className="gm-mono mb-6 flex items-center gap-3 text-[11px] uppercase tracking-[0.24em] text-[var(--gm-blue-soft)]">
<motion.span initial={{ width: 0 }} animate={{ width: 32 }} transition={{ duration: 0.8, ease: EASE }} className="h-px bg-[var(--gm-blue-soft)]" />
<span>
{slide.id} — {slide.eyebrow}
</span>
</div>
<h1
key={slide.id}
aria-live="polite"
className="gm-display select-none text-[16.5vw] font-bold uppercase leading-[0.88] text-white sm:text-[96px] lg:text-[clamp(78px,7.4vw,124px)]"
>
{slide.lines.map((line, i) => (
<span key={line} className="block overflow-hidden pb-[0.04em]">
<motion.span
className={cn("block", i === 1 && "text-white/90", i === 3 && "text-[#A9B8FF]")}
initial={{ y: "105%" }}
animate={{ y: 0 }}
transition={{ duration: 0.9, delay: 0.08 * i, ease: EASE }}
>
{line}
</motion.span>
</span>
))}
</h1>
</div>
<div
className="absolute right-6 top-[4%] hidden select-none flex-col gap-1 text-[12px] leading-relaxed tracking-wide text-white/70 sm:right-12 sm:flex lg:right-16"
style={{ writingMode: "vertical-rl" }}
>
<AnimatePresence mode="wait">
<motion.span key={slide.id} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} transition={{ duration: 0.4 }} className="flex flex-col gap-1">
<span>{slide.side[0]}</span>
<span className="text-white/45">{slide.side[1]}</span>
</motion.span>
</AnimatePresence>
</div>
</div>
{/* Controls and stats */}
<div className="relative z-20 grid w-full grid-cols-1 items-end gap-8 px-6 pb-8 sm:px-12 sm:pb-10 lg:grid-cols-12 lg:px-16">
<div className="space-y-5 lg:col-span-6">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => go(-1)}
aria-label="Previous slide"
className="grid size-10 place-items-center rounded-full border border-white/15 text-white/70 transition-colors hover:border-white/40 hover:text-white"
>
<ArrowLeft className="size-[18px] stroke-[1.5]" />
</button>
<button
type="button"
onClick={() => go(1)}
aria-label="Next slide"
className="grid size-10 place-items-center rounded-full border border-white/15 text-white/70 transition-colors hover:border-white/40 hover:text-white"
>
<ArrowRight className="size-[18px] stroke-[1.5]" />
</button>
<span className="gm-mono ml-2 text-[12px] tabular-nums text-white/50">
<span className="text-white">{slide.id}</span> / {String(HERO_SLIDES.length).padStart(2, "0")}
</span>
</div>
<div className="flex max-w-[620px] gap-2">
{HERO_SLIDES.map((s, i) => (
<button
key={s.id}
type="button"
onClick={() => setSlideIdx(i)}
aria-label={`Go to slide ${i + 1}: ${s.eyebrow}`}
aria-current={i === slideIdx}
className="group flex-1 py-2"
>
<span className="relative block h-[2px] w-full overflow-hidden bg-white/15">
{i < slideIdx && <span className="absolute inset-0 bg-white/60" />}
{i === slideIdx && (
<motion.span
key={`${s.id}-${paused}`}
className="absolute inset-y-0 left-0 bg-white"
initial={{ width: paused ? "100%" : "0%" }}
animate={{ width: "100%" }}
transition={{ duration: paused ? 0 : SLIDE_MS / 1000, ease: "linear" }}
/>
)}
</span>
<span className="gm-mono mt-2 block text-left text-[10px] uppercase tracking-[0.2em] text-white/40 transition-colors group-hover:text-white/80">
{s.eyebrow}
</span>
</button>
))}
</div>
</div>
<div className="flex items-end justify-start gap-10 lg:col-span-6 lg:justify-end">
{slide.stats.map((stat) => (
<div key={`${slide.id}-${stat.label}`} className="lg:text-right">
<div className="gm-display text-[44px] font-bold leading-none tabular-nums text-white sm:text-[56px]">
<RollingNumber value={stat.value} format={(n) => n.toFixed(stat.decimals) + (stat.suffix ?? "")} />
</div>
<div className="mt-1.5 text-[12px] text-white/60">{stat.label}</div>
</div>
))}
</div>
</div>
</section>
);
}
function MobileMenu({ open, onClose }: { open: boolean; onClose: () => void }) {
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.3 }}
className="fixed inset-0 z-30 flex flex-col justify-center bg-[#0B0B0F]/96 px-8 backdrop-blur-xl sm:px-16"
>
<nav aria-label="Menu" className="mx-auto w-full max-w-4xl">
{[{ label: "Home", href: "#top" }, ...NAV].map((item, i) => (
<motion.a
key={item.href}
href={item.href}
onClick={onClose}
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.06 * i + 0.1, duration: 0.5, ease: EASE }}
className="group flex items-baseline gap-6 border-b border-white/10 py-3"
>
<span className="gm-mono text-xs text-white/40">0{i + 1}</span>
<span className="gm-display text-5xl uppercase text-white transition-colors group-hover:text-[#8EA2FF] sm:text-7xl">{item.label}</span>
</motion.a>
))}
</nav>
</motion.div>
)}
</AnimatePresence>
);
}
/* ─── Titles marquee ───────────────────────────────────────────────────────── */
function MastheadStrip() {
const row = [...MASTHEADS, ...MASTHEADS];
return (
<section aria-label="Our titles" className="gm-marquee-host relative border-y border-white/10 bg-[#0B0B0F] py-8">
<div className="mx-auto mb-6 flex max-w-[1520px] items-center justify-between gap-4 px-6 sm:px-12 lg:px-16">
<p className="gm-mono text-[11px] uppercase tracking-[0.22em] text-white/45">Home to 84 titles across six divisions</p>
<a href="#divisions" className="gm-link hidden text-[12px] text-white/60 hover:text-white sm:inline">
Browse divisions
</a>
</div>
<div className="relative overflow-hidden [mask-image:linear-gradient(90deg,transparent,black_8%,black_92%,transparent)]">
<div className="gm-marquee flex w-max items-center">
{row.map((m, i) => (
<span key={i} aria-hidden={i >= MASTHEADS.length} className={cn("shrink-0 px-10 text-white/55 transition-colors hover:text-white", m.style)}>
{m.name}
</span>
))}
</div>
</div>
</section>
);
}
/* ─── Reach in numbers ─────────────────────────────────────────────────────── */
const REACH_STATS = [
{ value: 104.6, decimals: 1, suffix: "M", label: "Monthly unique audience", note: "Deduplicated across print, web, social and video", spark: [32, 38, 41, 47, 52, 58, 63, 71, 76, 84, 95, 104] },
{ value: 12.4, decimals: 1, suffix: "B", label: "Annual impressions", note: "Up 28.4% year on year", spark: [6, 6.4, 7.1, 7.6, 8.2, 8.9, 9.4, 10.1, 10.8, 11.3, 11.9, 12.4] },
{ value: 4.5, decimals: 1, suffix: "M", label: "Newsletter & social followers", note: "Across 31 owned channels", spark: [2.1, 2.3, 2.6, 2.8, 3.0, 3.2, 3.5, 3.7, 3.9, 4.1, 4.3, 4.5] },
{ value: 4.8, decimals: 1, suffix: "x", label: "Dwell time vs. benchmark", note: "Average 4m 18s per visit", spark: [2.9, 3.1, 3.3, 3.2, 3.6, 3.8, 4.0, 4.1, 4.3, 4.5, 4.6, 4.8] },
];
function Sparkline({ data }: { data: number[] }) {
const min = Math.min(...data);
const max = Math.max(...data);
const pts = data.map((v, i) => [(i / (data.length - 1)) * 100, 28 - ((v - min) / (max - min || 1)) * 24 - 2]);
const line = pts.map(([x, y], i) => `${i ? "L" : "M"}${x.toFixed(1)} ${y.toFixed(1)}`).join(" ");
return (
<svg viewBox="0 0 100 30" preserveAspectRatio="none" className="h-8 w-full" aria-hidden>
<path d={`${line} L100 30 L0 30 Z`} fill="url(#gm-spark)" opacity={0.35} />
<path d={line} fill="none" stroke="#8EA2FF" strokeWidth={1.4} vectorEffect="non-scaling-stroke" />
<circle cx={pts[pts.length - 1][0]} cy={pts[pts.length - 1][1]} r={1.8} fill="#fff" />
</svg>
);
}
function StatCell({ stat, index }: { stat: (typeof REACH_STATS)[number]; index: number }) {
const ref = useRef<HTMLDivElement | null>(null);
const inView = useInView(ref, { once: true, margin: "-15% 0px" });
return (
<div ref={ref} className="flex flex-col justify-between gap-8 border-white/10 p-6 sm:p-8 [&:not(:first-child)]:border-t sm:[&:nth-child(2)]:border-t-0 sm:[&:nth-child(even)]:border-l lg:[&:not(:first-child)]:border-l lg:[&:not(:first-child)]:border-t-0">
<div className="gm-mono flex items-center justify-between text-[11px] text-white/40">
<span>0{index + 1}</span>
<span>12 mo</span>
</div>
<div>
<div className="gm-display text-[64px] font-bold leading-none tabular-nums text-white sm:text-[76px]">
<RollingNumber value={stat.value} start={inView} duration={1400} format={(n) => n.toFixed(stat.decimals)} />
<span className="text-[var(--gm-blue-soft)]">{stat.suffix}</span>
</div>
<div className="mt-3 text-[15px] font-medium text-white">{stat.label}</div>
<div className="mt-1 text-[13px] text-white/50">{stat.note}</div>
</div>
<Sparkline data={stat.spark} />
</div>
);
}
function ReachSection() {
return (
<section id="about" className="relative scroll-mt-6 bg-[var(--gm-bg)] py-24 lg:py-32">
<svg width="0" height="0" className="absolute" aria-hidden>
<defs>
<linearGradient id="gm-spark" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#2F54FF" />
<stop offset="1" stopColor="#2F54FF" stopOpacity="0" />
</linearGradient>
</defs>
</svg>
<div className="mx-auto max-w-[1520px] px-6 sm:px-12 lg:px-16">
<div className="grid gap-10 lg:grid-cols-12 lg:items-end">
<Reveal className="lg:col-span-7">
<SectionLabel index="01">Reach</SectionLabel>
<h2 className="gm-display mt-6 text-[52px] font-bold uppercase leading-[0.92] text-white sm:text-[80px]">
One audience.
<br />
<span className="text-white/35">Every format.</span>
</h2>
</Reveal>
<Reveal className="lg:col-span-5" delay={0.1}>
<p className="max-w-md text-[16px] leading-relaxed text-white/65">
Thirty years of trusted journalism, now read, watched and attended by one in three adults across the Gulf. Advertisers buy it as a single, measurable audience.
</p>
</Reveal>
</div>
<Reveal delay={0.15} className="mt-14 grid grid-cols-1 overflow-hidden rounded-2xl border border-white/10 bg-white/[0.02] sm:grid-cols-2 lg:grid-cols-4">
{REACH_STATS.map((stat, i) => (
<StatCell key={stat.label} stat={stat} index={i} />
))}
</Reveal>
</div>
</section>
);
}
/* ─── Divisions index ──────────────────────────────────────────────────────── */
function MixBar({ mix }: { mix: Division["mix"] }) {
return (
<div>
<div className="flex h-2.5 w-full overflow-hidden rounded-full bg-white/10">
{MIX_KEYS.map((k) =>
mix[k] > 0 ? (
<motion.span
key={k}
layout
className="h-full"
style={{ background: MIX_COLOR[k] }}
initial={false}
animate={{ width: `${mix[k]}%` }}
transition={{ duration: 0.6, ease: EASE }}
/>
) : null,
)}
</div>
<div className="mt-3 flex flex-wrap gap-x-4 gap-y-1.5">
{MIX_KEYS.map((k) => (
<span key={k} className="inline-flex items-center gap-1.5 text-[11.5px] text-white/55">
<span className="size-2 rounded-full" style={{ background: MIX_COLOR[k] }} />
{MIX_LABEL[k]} <span className="gm-mono tabular-nums text-white/80">{mix[k]}%</span>
</span>
))}
</div>
</div>
);
}
function DivisionsSection({ active, setActive, onPlan }: { active: number; setActive: (i: number) => void; onPlan: (i: number) => void }) {
const d = DIVISIONS[active];
const maxReach = Math.max(...DIVISIONS.map((x) => x.reach));
return (
<section id="divisions" className="relative scroll-mt-6 border-t border-white/10 bg-[#0B0B0F] py-24 lg:py-32">
<div className="mx-auto max-w-[1520px] px-6 sm:px-12 lg:px-16">
<div className="grid gap-10 lg:grid-cols-12 lg:items-end">
<Reveal className="lg:col-span-7">
<SectionLabel index="02">Divisions</SectionLabel>
<h2 className="gm-display mt-6 text-[52px] font-bold uppercase leading-[0.92] text-white sm:text-[80px]">
Six divisions.
<br />
<span className="text-white/35">Eighty-four titles.</span>
</h2>
</Reveal>
<Reveal className="lg:col-span-5" delay={0.1}>
<p className="max-w-md text-[16px] leading-relaxed text-white/65">
Each division runs its own editorial team and sells its own audience. Pick one to see who it reaches and where.
</p>
</Reveal>
</div>
<div className="mt-14 grid gap-8 lg:grid-cols-12">
<div role="tablist" aria-label="Divisions" aria-orientation="vertical" className="border-t border-white/10 lg:col-span-7">
{DIVISIONS.map((div, i) => {
const on = i === active;
return (
<button
key={div.code}
type="button"
role="tab"
id={`gm-div-tab-${i}`}
aria-selected={on}
aria-controls="gm-div-panel"
onClick={() => setActive(i)}
onKeyDown={(e) => {
if (e.key === "ArrowDown" || e.key === "ArrowUp") {
e.preventDefault();
const next = (i + (e.key === "ArrowDown" ? 1 : -1) + DIVISIONS.length) % DIVISIONS.length;
setActive(next);
document.getElementById(`gm-div-tab-${next}`)?.focus();
}
}}
className="group relative grid w-full grid-cols-[2.5rem_1fr_auto] items-center gap-4 border-b border-white/10 py-5 text-left outline-none sm:grid-cols-[3rem_1fr_9rem_auto] sm:py-6"
>
<span className={cn("absolute inset-y-0 -left-4 w-[3px] origin-top bg-[var(--gm-blue)] transition-transform duration-500", on ? "scale-y-100" : "scale-y-0")} />
<span className={cn("gm-mono text-[12px] transition-colors", on ? "text-[var(--gm-blue-soft)]" : "text-white/35")}>{div.code}</span>
<span
className={cn(
"gm-display text-[30px] font-semibold uppercase leading-none transition-all duration-500 sm:text-[44px] group-focus-visible:underline",
on ? "translate-x-1 text-white" : "text-white/40 group-hover:text-white/70",
)}
>
{div.name}
</span>
<span className="hidden h-1 overflow-hidden rounded-full bg-white/10 sm:block">
<span
className={cn("block h-full rounded-full transition-colors", on ? "bg-[var(--gm-blue)]" : "bg-white/30")}
style={{ width: `${(div.reach / maxReach) * 100}%` }}
/>
</span>
<span className={cn("gm-display text-right text-[24px] tabular-nums transition-colors sm:text-[28px]", on ? "text-white" : "text-white/40")}>
{div.reach.toFixed(1)}M
</span>
</button>
);
})}
</div>
<div className="lg:col-span-5">
<div
id="gm-div-panel"
role="tabpanel"
aria-labelledby={`gm-div-tab-${active}`}
className="relative overflow-hidden rounded-2xl border border-white/10 bg-[#12131A] lg:sticky lg:top-8"
>
<div aria-hidden className="pointer-events-none absolute -right-24 -top-24 size-72 rounded-full bg-[var(--gm-blue)] opacity-20 blur-[90px]" />
<AnimatePresence mode="wait">
<motion.div
key={d.code}
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.35, ease: EASE }}
className="relative p-6 sm:p-8"
>
<div className="flex items-start justify-between gap-4">
<div>
<div className="gm-mono text-[11px] uppercase tracking-[0.2em] text-white/40">Division {d.code}</div>
<h3 className="mt-2 text-[22px] font-semibold tracking-tight text-white">{d.name}</h3>
</div>
<span className="gm-mono shrink-0 rounded-full border border-emerald-400/25 bg-emerald-400/10 px-2.5 py-1 text-[11px] text-emerald-300">
+{d.growth.toFixed(1)}% YoY
</span>
</div>
<p className="mt-3 text-[14px] leading-relaxed text-white/60">{d.blurb}</p>
<div className="mt-6 flex flex-wrap gap-2">
{d.titles.map((t) => (
<span key={t} className="gm-serif rounded-full border border-white/12 bg-white/[0.04] px-3 py-1 text-[13px] italic text-white/85">
{t}
</span>
))}
</div>
<div className="mt-8 grid grid-cols-3 gap-px overflow-hidden rounded-xl border border-white/10 bg-white/10">
{[
{ k: "Monthly reach", v: `${d.reach.toFixed(1)}M` },
{ k: `Aged ${d.audience.core}`, v: `${d.audience.corePct}%` },
{ k: "Top-decile income", v: `${d.audience.affluent}%` },
].map((m) => (
<div key={m.k} className="bg-[#12131A] p-3.5">
<div className="gm-display text-[28px] font-semibold leading-none text-white">{m.v}</div>
<div className="mt-1.5 text-[11px] leading-snug text-white/45">{m.k}</div>
</div>
))}
</div>
<div className="mt-8">
<div className="gm-mono mb-3 text-[11px] uppercase tracking-[0.2em] text-white/40">Where the audience reads</div>
<MixBar mix={d.mix} />
</div>
<a
href="#advertise"
onClick={() => onPlan(active)}
className="group mt-8 inline-flex items-center gap-2 text-[13px] font-medium text-white"
>
<span className="gm-link">Plan a campaign in {d.short}</span>
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
</a>
</motion.div>
</AnimatePresence>
</div>
</div>
</div>
</div>
</section>
);
}
/* ─── Bureau network map ───────────────────────────────────────────────────── */
const MAP_TOP = 12; // 78°N
const MAP_HEIGHT = 136; // down to 58°S
const HQ = BUREAUS[0];
function buildLandPath() {
const mask = decodeLandMask();
let d = "";
for (let row = MAP_TOP; row < MAP_TOP + MAP_HEIGHT; row += 2) {
for (let col = 0; col < LAND_W; col += 2) {
if (isLand(mask, row, col) || isLand(mask, row + 1, col + 1)) d += `M${col + 1} ${row + 1}h0`;
}
}
return d;
}
const toMap = (b: Bureau) => ({ x: b.lon + 180, y: 90 - b.lat });
function arcPath(a: Bureau, b: Bureau) {
const p = toMap(a);
const q = toMap(b);
const mx = (p.x + q.x) / 2;
const my = (p.y + q.y) / 2;
const dist = Math.hypot(q.x - p.x, q.y - p.y);
return `M${p.x} ${p.y} Q${mx} ${my - dist * 0.32} ${q.x} ${q.y}`;
}
function BureausSection({ active, setActive }: { active: string; setActive: (id: string) => void }) {
const landPath = useMemo(() => buildLandPath(), []);
const minute = useMinute();
const bureau = BUREAUS.find((b) => b.id === active) ?? HQ;
const t = localTime(minute, bureau.utc);
const onAir = t.hour >= 6 && t.hour < 23;
const onPinKey = (e: ReactKeyboardEvent, id: string) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActive(id);
}
};
return (
<section id="bureaus" className="relative scroll-mt-6 overflow-hidden border-t border-white/10 bg-[var(--gm-bg)] py-24 lg:py-32">
<div className="mx-auto max-w-[1520px] px-6 sm:px-12 lg:px-16">
<div className="grid gap-10 lg:grid-cols-12 lg:items-end">
<Reveal className="lg:col-span-7">
<SectionLabel index="03">Bureaus</SectionLabel>
<h2 className="gm-display mt-6 text-[52px] font-bold uppercase leading-[0.92] text-white sm:text-[80px]">
{BUREAUS.length} bureaus.
<br />
<span className="text-white/35">{TOTAL_STAFF} journalists.</span>
</h2>
</Reveal>
<Reveal className="lg:col-span-5" delay={0.1}>
<p className="max-w-md text-[16px] leading-relaxed text-white/65">
Stories are reported where they happen and edited in Dubai. When one desk signs off for the night, another is already on air.
</p>
</Reveal>
</div>
<Reveal delay={0.1} className="mt-14 grid gap-6 lg:grid-cols-12">
<div className="relative flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#0B0B0F] lg:col-span-8">
<div className="gm-mono flex items-center justify-between border-b border-white/10 px-5 py-3 text-[11px] text-white/45">
<span className="inline-flex items-center gap-2">
<span className="relative flex size-2">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-emerald-400 opacity-60" />
<span className="relative inline-flex size-2 rounded-full bg-emerald-400" />
</span>
Live network
</span>
<span className="hidden sm:inline">Select a bureau</span>
</div>
<div className="flex flex-1 items-center px-2 py-4 sm:px-4">
<svg viewBox={`0 ${MAP_TOP} 360 ${MAP_HEIGHT}`} className="block h-auto w-full" role="group" aria-label="Map of Meridian bureaus">
<path d={landPath} stroke="rgba(142,162,255,0.32)" strokeWidth={1.05} strokeLinecap="round" fill="none" />
{BUREAUS.slice(1).map((b) => (
<path
key={b.id}
d={arcPath(HQ, b)}
fill="none"
stroke={b.id === active ? "#FFFFFF" : "rgba(47,84,255,0.75)"}
strokeWidth={b.id === active ? 0.6 : 0.4}
className="gm-arc transition-[stroke]"
/>
))}
{BUREAUS.map((b) => {
const p = toMap(b);
const on = b.id === active;
const hq = b.id === HQ.id;
return (
<g
key={b.id}
role="button"
tabIndex={0}
aria-label={`${b.city} bureau`}
aria-pressed={on}
onClick={() => setActive(b.id)}
onKeyDown={(e) => onPinKey(e, b.id)}
className="group cursor-pointer outline-none"
>
<circle cx={p.x} cy={p.y} r={7} fill="transparent" />
{on && <circle cx={p.x} cy={p.y} r={2.2} fill="none" stroke="#fff" strokeWidth={0.4} className="gm-ping" />}
<circle
cx={p.x}
cy={p.y}
r={on ? 2.4 : hq ? 2.1 : 1.6}
fill={on ? "#FFFFFF" : hq ? "#8EA2FF" : "#2F54FF"}
stroke="#0B0B0F"
strokeWidth={0.6}
className="transition-all group-hover:fill-white group-focus-visible:fill-white"
/>
<text
x={p.x + 3.6}
y={p.y + 1.2}
className={cn(
"gm-mono pointer-events-none fill-white text-[3.4px] transition-opacity",
on || hq ? "opacity-100" : "opacity-0 group-hover:opacity-100 group-focus-visible:opacity-100",
)}
>
{b.city.toUpperCase()}
</text>
</g>
);
})}
</svg>
</div>
<div className="flex flex-wrap gap-1.5 border-t border-white/10 p-4">
{BUREAUS.map((b) => (
<button
key={b.id}
type="button"
onClick={() => setActive(b.id)}
aria-pressed={b.id === active}
className={cn(
"rounded-full border px-3 py-1 text-[12px] transition-colors",
b.id === active ? "border-white bg-white text-[#0E0E12]" : "border-white/15 text-white/65 hover:text-white",
)}
>
{b.city}
</button>
))}
</div>
</div>
<div className="flex flex-col gap-6 lg:col-span-4">
<div className="relative flex-1 overflow-hidden rounded-2xl border border-white/10 bg-[#12131A] p-6 sm:p-7">
<AnimatePresence mode="wait">
<motion.div key={bureau.id} initial={{ opacity: 0, y: 10 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0, y: -6 }} transition={{ duration: 0.3, ease: EASE }}>
<div className="flex items-center justify-between gap-3">
<span className="gm-mono text-[11px] uppercase tracking-[0.2em] text-white/40">{bureau.id === HQ.id ? "Headquarters" : "Bureau"}</span>
<span
className={cn(
"gm-mono inline-flex items-center gap-1.5 rounded-full px-2.5 py-1 text-[10.5px]",
onAir ? "bg-emerald-400/10 text-emerald-300" : "bg-amber-400/10 text-amber-300",
)}
>
<Radio className="size-3" />
{minute === null ? "Connecting" : onAir ? "On air" : "Overnight desk"}
</span>
</div>
<div className="mt-4 flex items-end justify-between gap-4">
<div>
<h3 className="gm-display text-[48px] font-bold uppercase leading-none text-white">{bureau.city}</h3>
<div className="mt-1 text-[13px] text-white/55">{bureau.country}</div>
</div>
<div className="text-right">
<div className="gm-mono text-[26px] font-medium tabular-nums text-white">{t.label}</div>
<div className="gm-mono text-[10.5px] text-white/40">UTC{bureau.utc >= 0 ? "+" : ""}{bureau.utc}</div>
</div>
</div>
<p className="mt-5 text-[14px] text-white/70">{bureau.role}</p>
<dl className="mt-6 grid grid-cols-2 gap-4 border-t border-white/10 pt-5 text-[13px]">
<div>
<dt className="text-white/40">Journalists</dt>
<dd className="gm-display mt-1 text-[28px] font-semibold leading-none text-white">{bureau.staff}</dd>
</div>
<div>
<dt className="text-white/40">Languages</dt>
<dd className="gm-mono mt-2 text-white/85">{bureau.langs}</dd>
</div>
</dl>
<div className="mt-5 flex flex-wrap gap-1.5">
{bureau.beats.map((beat) => (
<span key={beat} className="rounded-md border border-white/10 bg-white/[0.04] px-2 py-1 text-[12px] text-white/70">
{beat}
</span>
))}
</div>
</motion.div>
</AnimatePresence>
</div>
<div className="grid grid-cols-3 gap-px overflow-hidden rounded-2xl border border-white/10 bg-white/10 text-center">
{[
{ v: "4", k: "Continents" },
{ v: "5", k: "Languages" },
{ v: "24/7", k: "Coverage" },
].map((m) => (
<div key={m.k} className="bg-[#0B0B0F] px-3 py-4">
<div className="gm-display text-[26px] font-semibold text-white">{m.v}</div>
<div className="text-[11px] text-white/45">{m.k}</div>
</div>
))}
</div>
</div>
</Reveal>
</div>
</section>
);
}
/* ─── Campaign planner ─────────────────────────────────────────────────────── */
const OBJECTIVES = [
{ id: "awareness", label: "Awareness", cpm: 1, eng: 0.8, hint: "Maximise unique reach" },
{ id: "consideration", label: "Consideration", cpm: 1.2, eng: 1.15, hint: "Video and long reads" },
{ id: "conversion", label: "Conversion", cpm: 1.5, eng: 1.5, hint: "Sponsored content and lead forms" },
] as const;
const WEEKS = [2, 4, 8, 12] as const;
function planCampaign(budget: number, weeks: number, objective: (typeof OBJECTIVES)[number], picked: number[]) {
const divs = picked.map((i) => DIVISIONS[i]);
const totalWeight = divs.reduce((s, d) => s + d.reach, 0);
const frequency = 1.4 + weeks * 0.22;
const rows = divs.map((d) => {
const spend = (budget * d.reach) / totalWeight;
const impressions = (spend / (d.cpm * objective.cpm)) * 1000;
const reach = Math.min(impressions / frequency, d.reach * 1e6 * 0.85);
return { d, spend, impressions, reach, engagements: impressions * (d.engagement / 100) * objective.eng };
});
const impressions = rows.reduce((s, r) => s + r.impressions, 0);
const reach = rows.reduce((s, r) => s + r.reach, 0);
const engagements = rows.reduce((s, r) => s + r.engagements, 0);
const mix = MIX_KEYS.map((k) => ({ k, v: rows.reduce((s, r) => s + r.impressions * r.d.mix[k], 0) / (impressions || 1) }));
return { rows, impressions, reach, engagements, cpm: (budget / impressions) * 1000, frequency: impressions / (reach || 1), mix };
}
function CampaignPlanner({ request }: { request: { division: number; n: number } }) {
const [budget, setBudget] = useState(150_000);
const [weeks, setWeeks] = useState<(typeof WEEKS)[number]>(4);
const [objectiveId, setObjectiveId] = useState<(typeof OBJECTIVES)[number]["id"]>("awareness");
const [picked, setPicked] = useState<number[]>([0, 1, 2]);
const [lastRequest, setLastRequest] = useState(request.n);
const [email, setEmail] = useState("");
const [sent, setSent] = useState(false);
// "Plan a campaign in …" from the divisions index adds that division here.
if (request.n !== lastRequest) {
setLastRequest(request.n);
if (!picked.includes(request.division)) setPicked([...picked, request.division].sort((a, b) => a - b));
}
const objective = OBJECTIVES.find((o) => o.id === objectiveId) ?? OBJECTIVES[0];
const plan = useMemo(() => planCampaign(budget, weeks, objective, picked), [budget, weeks, objective, picked]);
const maxRowReach = Math.max(...plan.rows.map((r) => r.reach));
const toggle = (i: number) => {
setPicked((cur) => (cur.includes(i) ? (cur.length > 1 ? cur.filter((x) => x !== i) : cur) : [...cur, i].sort((a, b) => a - b)));
};
const submit = (e: FormEvent) => {
e.preventDefault();
if (!/^\S+@\S+\.\S+$/.test(email)) return;
setSent(true);
};
const budgetPct = ((budget - 25_000) / (500_000 - 25_000)) * 100;
return (
<section id="advertise" className="relative scroll-mt-6 overflow-hidden border-t border-white/10 bg-[#0B0B0F] py-24 lg:py-32">
<div aria-hidden className="pointer-events-none absolute left-1/2 top-24 h-[420px] w-[900px] -translate-x-1/2 rounded-full bg-[radial-gradient(circle,#2F54FF_0%,#5B3FD9_45%,transparent_72%)] opacity-[0.16] blur-[120px]" />
<div className="relative mx-auto max-w-[1520px] px-6 sm:px-12 lg:px-16">
<div className="grid gap-10 lg:grid-cols-12 lg:items-end">
<Reveal className="lg:col-span-7">
<SectionLabel index="04">Advertise</SectionLabel>
<h2 className="gm-display mt-6 text-[52px] font-bold uppercase leading-[0.92] text-white sm:text-[80px]">
Plan a campaign
<br />
<span className="text-white/35">in thirty seconds.</span>
</h2>
</Reveal>
<Reveal className="lg:col-span-5" delay={0.1}>
<p className="max-w-md text-[16px] leading-relaxed text-white/65">
Set a budget and pick your divisions. The estimate uses this year's rate card and audience data, and our team will turn it into a full media plan.
</p>
</Reveal>
</div>
<Reveal delay={0.1} className="mt-14 grid overflow-hidden rounded-2xl border border-white/10 bg-[#12131A] lg:grid-cols-12">
{/* Controls */}
<div className="space-y-9 border-b border-white/10 p-6 sm:p-8 lg:col-span-5 lg:border-b-0 lg:border-r">
<div>
<div className="flex items-baseline justify-between">
<label htmlFor="gm-budget" className="text-[13px] font-medium text-white/80">
Budget
</label>
<span className="gm-display text-[34px] font-semibold tabular-nums leading-none text-white">${formatCompact(budget)}</span>
</div>
<input
id="gm-budget"
type="range"
min={25_000}
max={500_000}
step={5_000}
value={budget}
onChange={(e) => setBudget(Number(e.target.value))}
className="mt-4 h-1.5 w-full cursor-pointer appearance-none rounded-full outline-none [&::-moz-range-thumb]:size-5 [&::-moz-range-thumb]:rounded-full [&::-moz-range-thumb]:border-0 [&::-moz-range-thumb]:bg-white [&::-webkit-slider-thumb]:size-5 [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:shadow-[0_0_0_6px_rgba(47,84,255,0.35)] focus-visible:[&::-webkit-slider-thumb]:shadow-[0_0_0_8px_rgba(47,84,255,0.6)]"
style={{ background: `linear-gradient(90deg,#2F54FF ${budgetPct}%,rgba(255,255,255,0.12) ${budgetPct}%)` }}
/>
<div className="gm-mono mt-2 flex justify-between text-[10.5px] text-white/35">
<span>$25K</span>
<span>$500K</span>
</div>
</div>
<fieldset>
<legend className="mb-3 text-[13px] font-medium text-white/80">Flight</legend>
<div className="grid grid-cols-4 gap-1 rounded-xl border border-white/10 bg-black/20 p-1">
{WEEKS.map((w) => (
<button
key={w}
type="button"
aria-pressed={weeks === w}
onClick={() => setWeeks(w)}
className={cn(
"relative rounded-lg py-2 text-[13px] transition-colors",
weeks === w ? "text-[#0E0E12]" : "text-white/60 hover:text-white",
)}
>
{weeks === w && <motion.span layoutId="gm-weeks" className="absolute inset-0 rounded-lg bg-white" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}
<span className="relative">{w} wks</span>
</button>
))}
</div>
</fieldset>
<fieldset>
<legend className="mb-3 text-[13px] font-medium text-white/80">Objective</legend>
<div className="space-y-2">
{OBJECTIVES.map((o) => {
const on = o.id === objectiveId;
return (
<button
key={o.id}
type="button"
aria-pressed={on}
onClick={() => setObjectiveId(o.id)}
className={cn(
"flex w-full items-center justify-between rounded-xl border px-4 py-3 text-left transition-colors",
on ? "border-[var(--gm-blue)] bg-[var(--gm-blue)]/12" : "border-white/10 hover:border-white/25",
)}
>
<span>
<span className="block text-[14px] font-medium text-white">{o.label}</span>
<span className="block text-[12px] text-white/45">{o.hint}</span>
</span>
<span className={cn("grid size-5 place-items-center rounded-full border", on ? "border-[var(--gm-blue)] bg-[var(--gm-blue)]" : "border-white/25")}>
{on && <Check className="size-3 text-white" />}
</span>
</button>
);
})}
</div>
</fieldset>
<fieldset>
<legend className="mb-3 text-[13px] font-medium text-white/80">Divisions</legend>
<div className="flex flex-wrap gap-2">
{DIVISIONS.map((d, i) => {
const on = picked.includes(i);
return (
<button
key={d.code}
type="button"
aria-pressed={on}
onClick={() => toggle(i)}
className={cn(
"inline-flex items-center gap-1.5 rounded-full border px-3 py-1.5 text-[12.5px] transition-colors",
on ? "border-white bg-white text-[#0E0E12]" : "border-white/15 text-white/60 hover:border-white/35 hover:text-white",
)}
>
{on && <Check className="size-3" />}
{d.short}
</button>
);
})}
</div>
</fieldset>
</div>
{/* Estimate */}
<div className="flex flex-col p-6 sm:p-8 lg:col-span-7">
<div className="flex items-center justify-between">
<span className="gm-mono text-[11px] uppercase tracking-[0.2em] text-white/40">Estimated delivery</span>
<span className="gm-mono text-[11px] text-white/40">
{weeks} weeks · {objective.label}
</span>
</div>
<div className="mt-6 grid grid-cols-2 gap-px overflow-hidden rounded-xl border border-white/10 bg-white/10 sm:grid-cols-4">
{[
{ k: "Unique reach", v: plan.reach, f: formatCompact, big: true },
{ k: "Impressions", v: plan.impressions, f: formatCompact },
{ k: "Effective CPM", v: plan.cpm, f: (n: number) => `$${n.toFixed(2)}` },
{ k: "Engagements", v: plan.engagements, f: formatCompact },
].map((m) => (
<div key={m.k} className={cn("bg-[#12131A] p-4", m.big && "bg-[var(--gm-blue)]")}>
<div className="gm-display text-[34px] font-semibold leading-none tabular-nums text-white sm:text-[38px]">
<RollingNumber value={m.v} format={m.f} duration={600} />
</div>
<div className={cn("mt-2 text-[11.5px]", m.big ? "text-white/80" : "text-white/45")}>{m.k}</div>
</div>
))}
</div>
<div className="mt-8">
<div className="gm-mono mb-3 flex justify-between text-[11px] uppercase tracking-[0.2em] text-white/40">
<span>Reach by division</span>
<span>Spend</span>
</div>
<ul className="space-y-3">
{plan.rows.map((r) => (
<li key={r.d.code} className="grid grid-cols-[7.5rem_1fr_4.5rem] items-center gap-3 text-[13px]">
<span className="truncate text-white/75">{r.d.short}</span>
<span className="h-2 overflow-hidden rounded-full bg-white/[0.07]">
<motion.span
className="block h-full rounded-full bg-gradient-to-r from-[#2F54FF] to-[#8EA2FF]"
initial={false}
animate={{ width: `${(r.reach / maxRowReach) * 100}%` }}
transition={{ duration: 0.5, ease: EASE }}
/>
</span>
<span className="gm-mono text-right tabular-nums text-white/60">${formatCompact(r.spend)}</span>
</li>
))}
</ul>
</div>
<dl className="mt-8 grid grid-cols-3 gap-4 border-y border-white/10 py-4 text-[12px]">
{[
{ k: "Avg. frequency", v: `${plan.frequency.toFixed(1)}x` },
{ k: "Per week", v: `${formatCompact(plan.impressions / weeks)} imp.` },
{ k: "Cost per person", v: `$${(budget / (plan.reach || 1)).toFixed(3)}` },
].map((m) => (
<div key={m.k}>
<dt className="text-white/40">{m.k}</dt>
<dd className="gm-mono mt-1 text-[14px] tabular-nums text-white">{m.v}</dd>
</div>
))}
</dl>
<div className="mt-8">
<div className="gm-mono mb-3 text-[11px] uppercase tracking-[0.2em] text-white/40">Format mix</div>
<MixBar mix={Object.fromEntries(plan.mix.map(({ k, v }) => [k, Math.round(v)])) as Division["mix"]} />
</div>
<div className="mt-auto pt-8">
<div className="rounded-xl border border-white/10 bg-black/20 p-4">
<AnimatePresence mode="wait" initial={false}>
{sent ? (
<motion.div key="sent" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} exit={{ opacity: 0 }} className="flex items-center justify-between gap-3">
<span className="flex items-center gap-3 text-[13px] text-white/80">
<span className="grid size-8 place-items-center rounded-full bg-emerald-400/15 text-emerald-300">
<Check className="size-4" />
</span>
Plan and media kit sent to <span className="font-medium text-white">{email}</span>
</span>
<button type="button" onClick={() => setSent(false)} className="gm-link shrink-0 text-[12px] text-white/50 hover:text-white">
Change
</button>
</motion.div>
) : (
<motion.form key="form" onSubmit={submit} initial={{ opacity: 0 }} animate={{ opacity: 1 }} exit={{ opacity: 0 }} className="flex flex-col gap-2 sm:flex-row">
<label htmlFor="gm-email" className="sr-only">
Work email
</label>
<div className="flex flex-1 items-center gap-2 rounded-lg border border-white/10 bg-white/[0.04] px-3 focus-within:border-[var(--gm-blue)]">
<Mail className="size-4 text-white/40" />
<input
id="gm-email"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@brand.com"
className="h-11 w-full bg-transparent text-[14px] text-white placeholder:text-white/30 focus:outline-none"
/>
</div>
<button type="submit" className="inline-flex h-11 items-center justify-center gap-2 rounded-lg bg-white px-5 text-[13px] font-semibold text-[#0E0E12] transition-colors hover:bg-[#DCE2FF]">
<Download className="size-4" />
Send me this plan
</button>
</motion.form>
)}
</AnimatePresence>
</div>
<p className="mt-3 text-[11.5px] text-white/35">Estimates only. Final delivery depends on inventory and creative.</p>
</div>
</div>
</Reveal>
</div>
</section>
);
}
/* ─── Newsroom ─────────────────────────────────────────────────────────────── */
function StoryArt({ art, large = false }: { art: StoryArtSpec; large?: boolean }) {
const pattern: Record<ArtPattern, { backgroundImage: string; backgroundSize: string }> = {
dots: { backgroundImage: `radial-gradient(${art.accent}66 1px, transparent 1.5px)`, backgroundSize: large ? "14px 14px" : "11px 11px" },
grid: {
backgroundImage: `linear-gradient(${art.accent}26 1px, transparent 1px), linear-gradient(90deg, ${art.accent}26 1px, transparent 1px)`,
backgroundSize: large ? "36px 36px" : "22px 22px",
},
lines: { backgroundImage: `repeating-linear-gradient(115deg, ${art.accent}24 0 1px, transparent 1px 9px)`, backgroundSize: "auto" },
rings: { backgroundImage: `repeating-radial-gradient(circle at 78% 82%, ${art.accent}30 0 1px, transparent 1px 16px)`, backgroundSize: "auto" },
};
return (
<div className="relative size-full overflow-hidden" style={{ background: `linear-gradient(140deg, ${art.from}, ${art.to})` }}>
<div aria-hidden className="absolute inset-0 transition-transform duration-[1.2s] ease-out group-hover:scale-110" style={pattern[art.pattern]} />
<div
aria-hidden
className="absolute -bottom-[30%] -right-[18%] aspect-square w-[70%] rounded-full transition-transform duration-[1.2s] ease-out group-hover:-translate-x-[4%] group-hover:-translate-y-[4%]"
style={{ background: `radial-gradient(circle at 32% 30%, ${art.accent}CC, ${art.accent}33 45%, transparent 70%)` }}
/>
<div aria-hidden className="absolute inset-x-0 bottom-0 h-1/2 bg-gradient-to-t from-black/45 to-transparent" />
<div aria-hidden className="gm-grain absolute inset-0 opacity-[0.12] mix-blend-overlay" />
<span className={cn("gm-display absolute bottom-3 left-4 font-bold leading-none text-white/90", large ? "text-[96px] sm:text-[140px]" : "text-[56px]")}>{art.glyph}</span>
</div>
);
}
function Byline({ story }: { story: Story }) {
const initials = story.author
.split(" ")
.map((w) => w[0])
.join("")
.slice(0, 2);
return (
<div className="flex items-center gap-2.5 text-[12px] text-white/45">
<span className="grid size-6 place-items-center rounded-full bg-white/10 text-[10px] font-semibold text-white/80">{initials}</span>
<span className="text-white/70">{story.author}</span>
<span>·</span>
<span>{story.when}</span>
<span>·</span>
<span>{story.read} min</span>
</div>
);
}
function Kicker({ story }: { story: Story }) {
return (
<div className="gm-mono flex flex-wrap items-center gap-x-2.5 gap-y-1 text-[10.5px] uppercase tracking-[0.16em] text-white/45">
<span className="text-[var(--gm-blue-soft)]">{DESK_LABEL[story.desk]}</span>
<span>·</span>
<span>{story.bureau}</span>
</div>
);
}
function NewsletterSignup() {
const [email, setEmail] = useState("");
const [done, setDone] = useState(false);
return (
<div className="relative mt-6 overflow-hidden rounded-2xl border border-white/10 bg-[#12131A] p-6 sm:p-8">
<div aria-hidden className="pointer-events-none absolute -left-20 -top-24 size-72 rounded-full bg-[var(--gm-blue)] opacity-25 blur-[90px]" />
<div className="relative grid gap-6 lg:grid-cols-12 lg:items-center">
<div className="lg:col-span-6">
<div className="gm-mono text-[11px] uppercase tracking-[0.2em] text-[var(--gm-blue-soft)]">The Morning Brief</div>
<h3 className="gm-serif mt-3 text-[26px] font-semibold leading-tight text-white sm:text-[30px]">Five stories from twelve bureaus, before your first coffee.</h3>
<p className="mt-2 text-[14px] text-white/55">Every weekday at 6am Gulf time. 410,000 readers. One click to leave.</p>
</div>
<div className="lg:col-span-6">
<AnimatePresence mode="wait" initial={false}>
{done ? (
<motion.div key="ok" initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="flex items-center gap-3 rounded-xl border border-emerald-400/25 bg-emerald-400/10 px-4 py-4 text-[14px] text-emerald-200">
<Check className="size-4" />
You're in. The first brief lands tomorrow at 6am.
</motion.div>
) : (
<motion.form
key="form"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onSubmit={(e) => {
e.preventDefault();
if (/^\S+@\S+\.\S+$/.test(email)) setDone(true);
}}
className="flex flex-col gap-2 sm:flex-row"
>
<label htmlFor="gm-brief" className="sr-only">
Email
</label>
<input
id="gm-brief"
type="email"
required
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@email.com"
className="h-12 flex-1 rounded-xl border border-white/10 bg-white/[0.04] px-4 text-[14px] text-white placeholder:text-white/30 focus:border-[var(--gm-blue)] focus:outline-none"
/>
<button type="submit" className="inline-flex h-12 items-center justify-center gap-2 rounded-xl bg-white px-5 text-[13px] font-semibold text-[#0E0E12] transition-colors hover:bg-[#DCE2FF]">
Subscribe
<ArrowRight className="size-4" />
</button>
</motion.form>
)}
</AnimatePresence>
</div>
</div>
</div>
);
}
function NewsroomSection() {
const [desk, setDesk] = useState<DeskId | "all">("all");
const ticker = [...LIVE_DESK, ...LIVE_DESK];
const filtered = desk === "all" ? STORIES : STORIES.filter((s) => s.desk === desk);
const [lead, ...rest] = filtered;
// A single desk only has a couple of stories, so top the row up from the other desks.
const grid = [...rest, ...STORIES.filter((s) => !filtered.includes(s))].slice(0, 4);
const mostRead = MOST_READ.map((id) => STORIES.find((s) => s.id === id)).filter((s): s is Story => Boolean(s));
return (
<section id="newsroom" className="relative scroll-mt-6 border-t border-white/10 bg-[var(--gm-bg)] pb-24 lg:pb-32">
{/* Live desk ticker */}
<div className="gm-marquee-host flex items-stretch border-b border-white/10 bg-[#0B0B0F]">
<div className="gm-mono z-10 flex shrink-0 items-center gap-2 bg-[var(--gm-blue)] px-4 text-[11px] font-semibold uppercase tracking-[0.18em] text-white sm:px-6">
<Radio className="size-3.5" />
Live desk
</div>
<div className="relative flex-1 overflow-hidden py-3.5 [mask-image:linear-gradient(90deg,transparent,black_4%,black_96%,transparent)]">
<div className="gm-marquee-fast flex w-max items-center">
{ticker.map((item, i) => (
<span key={i} aria-hidden={i >= LIVE_DESK.length} className="flex shrink-0 items-center gap-3 px-6 text-[13px]">
<span className="gm-mono text-white/40">{item.time}</span>
<span className="gm-mono text-[11px] uppercase tracking-[0.16em] text-[var(--gm-blue-soft)]">{item.bureau}</span>
<span className="text-white/80">{item.text}</span>
<span className="ml-3 size-1 rounded-full bg-white/25" />
</span>
))}
</div>
</div>
</div>
<div className="mx-auto max-w-[1520px] px-6 pt-24 sm:px-12 lg:px-16 lg:pt-32">
<div className="grid gap-10 lg:grid-cols-12 lg:items-end">
<Reveal className="lg:col-span-7">
<SectionLabel index="05">Newsroom</SectionLabel>
<h2 className="gm-display mt-6 text-[52px] font-bold uppercase leading-[0.92] text-white sm:text-[80px]">
From the desks
<br />
<span className="text-white/35">this week.</span>
</h2>
</Reveal>
<Reveal className="lg:col-span-5" delay={0.1}>
<p className="max-w-md text-[16px] leading-relaxed text-white/65">
{STORIES.length * 31} stories filed this week from {BUREAUS.length} bureaus. Here is what our editors are reading, and what you are.
</p>
</Reveal>
</div>
{/* Desk filter */}
<div role="tablist" aria-label="Filter by desk" className="mt-12 flex gap-2 overflow-x-auto border-b border-white/10 pb-4 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
{DESKS.map((d) => {
const on = d.id === desk;
const count = d.id === "all" ? STORIES.length : STORIES.filter((s) => s.desk === d.id).length;
return (
<button
key={d.id}
type="button"
role="tab"
aria-selected={on}
onClick={() => setDesk(d.id)}
className={cn(
"relative inline-flex shrink-0 items-center gap-2 rounded-full px-4 py-2 text-[13px] transition-colors",
on ? "text-[#0E0E12]" : "text-white/60 hover:text-white",
)}
>
{on && <motion.span layoutId="gm-desk" className="absolute inset-0 rounded-full bg-white" transition={{ type: "spring", stiffness: 500, damping: 40 }} />}
<span className="relative">{d.label}</span>
<span className={cn("gm-mono relative text-[10.5px]", on ? "text-[#0E0E12]/50" : "text-white/30")}>{count}</span>
</button>
);
})}
</div>
<div className="mt-8 grid gap-6 lg:grid-cols-12">
{/* Lead story */}
<div className="lg:col-span-8">
<AnimatePresence mode="wait">
<motion.a
key={lead.id}
href="#newsroom"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
transition={{ duration: 0.35, ease: EASE }}
className="group grid h-full overflow-hidden rounded-2xl border border-white/10 bg-[#12131A] transition-colors hover:border-white/25 md:grid-cols-5"
>
<div className="relative aspect-[16/10] md:col-span-3 md:aspect-auto md:min-h-[440px]">
<StoryArt art={lead.art} large />
<span className="gm-mono absolute left-4 top-4 rounded-full bg-black/40 px-2.5 py-1 text-[10.5px] uppercase tracking-[0.16em] text-white backdrop-blur-md">Lead story</span>
</div>
<div className="flex flex-col justify-between gap-8 p-6 sm:p-8 md:col-span-2">
<div>
<Kicker story={lead} />
<h3 className="gm-serif mt-4 text-[28px] font-semibold leading-[1.12] text-white transition-colors group-hover:text-[#C9D2FF] sm:text-[32px]">{lead.title}</h3>
<p className="mt-4 text-[15px] leading-relaxed text-white/60">{lead.dek}</p>
</div>
<div className="flex flex-col gap-5">
<Byline story={lead} />
<span className="inline-flex items-center gap-2 text-[13px] font-medium text-white">
<span className="gm-link">Read the story</span>
<ArrowRight className="size-4 transition-transform group-hover:translate-x-1" />
</span>
</div>
</div>
</motion.a>
</AnimatePresence>
</div>
{/* Most read */}
<aside className="rounded-2xl border border-white/10 bg-[#0B0B0F] p-6 lg:col-span-4">
<div className="flex items-center justify-between">
<div className="gm-mono text-[11px] uppercase tracking-[0.2em] text-white/45">Most read</div>
<div className="gm-mono text-[11px] text-white/30">24h</div>
</div>
<ol className="mt-4 divide-y divide-white/10">
{mostRead.map((s, i) => (
<li key={s.id}>
<a href="#newsroom" className="group grid grid-cols-[2.5rem_1fr] gap-3 py-4">
<span className="gm-display text-[34px] font-bold leading-none text-white/20 transition-colors group-hover:text-[var(--gm-blue-soft)]">{i + 1}</span>
<span>
<span className="gm-serif block text-[16px] font-semibold leading-snug text-white/90 transition-colors group-hover:text-white">{s.title}</span>
<span className="mt-1.5 block text-[12px] text-white/40">
{s.bureau} · {s.read} min
</span>
</span>
</a>
</li>
))}
</ol>
</aside>
</div>
{/* Story grid */}
{grid.length > 0 && (
<div className="mt-6 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
<AnimatePresence mode="popLayout">
{grid.map((s, i) => (
<motion.a
key={s.id}
layout
href="#newsroom"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.97 }}
transition={{ duration: 0.4, delay: 0.05 * i, ease: EASE }}
className="group flex flex-col overflow-hidden rounded-2xl border border-white/10 bg-[#12131A] transition-colors hover:border-white/25"
>
<div className="aspect-[4/3]">
<StoryArt art={s.art} />
</div>
<div className="flex flex-1 flex-col justify-between gap-5 p-5">
<div>
<Kicker story={s} />
<h3 className="gm-serif mt-3 text-[19px] font-semibold leading-snug text-white transition-colors group-hover:text-[#C9D2FF]">{s.title}</h3>
<p className="mt-2 line-clamp-2 text-[13px] leading-relaxed text-white/50">{s.dek}</p>
</div>
<Byline story={s} />
</div>
</motion.a>
))}
</AnimatePresence>
</div>
)}
{/* Watch */}
<div className="mt-16">
<div className="flex items-end justify-between gap-4">
<div>
<div className="gm-mono text-[11px] uppercase tracking-[0.2em] text-white/45">Watch</div>
<h3 className="gm-display mt-2 text-[34px] font-semibold uppercase leading-none text-white">From the studio</h3>
</div>
<a href="#newsroom" className="group hidden items-center gap-2 text-[13px] font-medium text-white sm:inline-flex">
<span className="gm-link">All videos</span>
<ArrowUpRight className="size-4" />
</a>
</div>
<div className="mt-6 grid gap-6 md:grid-cols-3">
{VIDEOS.map((v, i) => (
<Reveal key={v.title} delay={0.06 * i}>
<a href="#newsroom" className="group block">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-white/10">
<StoryArt art={v.art} />
<span className="absolute inset-0 grid place-items-center">
<span className="grid size-14 place-items-center rounded-full bg-white/90 text-[#0E0E12] shadow-[0_10px_40px_rgba(0,0,0,0.5)] transition-transform duration-300 group-hover:scale-110">
<Play className="ml-0.5 size-5 fill-current" />
</span>
</span>
<span className="gm-mono absolute bottom-3 right-3 rounded-md bg-black/60 px-2 py-0.5 text-[11px] text-white backdrop-blur-md">{v.duration}</span>
</div>
<div className="mt-4">
<div className="gm-mono text-[10.5px] uppercase tracking-[0.16em] text-[var(--gm-blue-soft)]">{v.series}</div>
<div className="mt-1.5 text-[16px] font-medium leading-snug text-white transition-colors group-hover:text-[#C9D2FF]">{v.title}</div>
</div>
</a>
</Reveal>
))}
</div>
</div>
<NewsletterSignup />
</div>
</section>
);
}
/* ─── Closing CTA & footer ─────────────────────────────────────────────────── */
function ClosingCta() {
return (
<section className="relative overflow-hidden border-t border-white/10 bg-[var(--gm-blue)] py-24 lg:py-32">
<div aria-hidden className="absolute inset-0 opacity-30" style={{ backgroundImage: "radial-gradient(rgba(255,255,255,0.35) 1px, transparent 1.4px)", backgroundSize: "18px 18px" }} />
<div aria-hidden className="absolute -right-40 -top-40 size-[640px] rounded-full bg-[radial-gradient(circle_at_35%_35%,#8EA2FF,transparent_60%)] opacity-60" />
<div className="relative mx-auto max-w-[1520px] px-6 sm:px-12 lg:px-16">
<Reveal>
<h2 className="gm-display max-w-5xl text-[56px] font-bold uppercase leading-[0.9] text-white sm:text-[88px] lg:text-[108px]">
Put your brand in front of 104.6M people.
</h2>
</Reveal>
<Reveal delay={0.1} className="mt-10 flex flex-col gap-3 sm:flex-row">
<a href="#advertise" className="inline-flex h-12 items-center justify-center gap-2 rounded-full bg-white px-6 text-[14px] font-semibold text-[#0E0E12] transition-transform hover:-translate-y-0.5">
Plan a campaign
<ArrowRight className="size-4" />
</a>
<a href="#advertise" className="inline-flex h-12 items-center justify-center gap-2 rounded-full border border-white/40 px-6 text-[14px] font-medium text-white transition-colors hover:bg-white/10">
<Download className="size-4" />
2026 media kit
</a>
</Reveal>
</div>
</section>
);
}
const FOOTER_COLS = [
{ title: "Company", links: ["About", "Leadership", "Careers", "Press"] },
{ title: "Divisions", links: DIVISIONS.slice(0, 4).map((d) => d.short) },
{ title: "Advertise", links: ["Media kit", "Rate card", "Branded content", "Events"] },
{ title: "Follow", links: ["Instagram", "LinkedIn", "YouTube", "Newsletter"] },
];
function Footer() {
return (
<footer className="bg-[#08080B] px-6 pb-10 pt-16 sm:px-12 lg:px-16">
<div className="mx-auto max-w-[1392px]">
<div className="grid gap-12 lg:grid-cols-12">
<div className="lg:col-span-4">
<Wordmark small />
<p className="mt-5 max-w-xs text-[13px] leading-relaxed text-white/50">
Independent journalism and the audiences it builds, from Dubai to the world since 1994.
</p>
</div>
<div className="grid grid-cols-2 gap-8 sm:grid-cols-4 lg:col-span-8">
{FOOTER_COLS.map((col) => (
<div key={col.title}>
<div className="gm-mono text-[11px] uppercase tracking-[0.2em] text-white/35">{col.title}</div>
<ul className="mt-4 space-y-2.5">
{col.links.map((l) => (
<li key={l}>
<a href="#top" className="gm-link text-[13px] text-white/65 transition-colors hover:text-white">
{l}
</a>
</li>
))}
</ul>
</div>
))}
</div>
</div>
<div className="mt-16 flex flex-col justify-between gap-4 border-t border-white/10 pt-6 text-[12px] text-white/40 sm:flex-row sm:items-center">
<span>© 2026 Meridian Media Group. All rights reserved.</span>
<span className="gm-mono inline-flex items-center gap-2 text-emerald-400/80">
<span className="size-1.5 rounded-full bg-emerald-400" />
All {BUREAUS.length} bureaus online
</span>
</div>
</div>
</footer>
);
}
/* ─── ⌘K search ────────────────────────────────────────────────────────────── */
interface SearchHit {
id: string;
group: "Divisions" | "Titles" | "Bureaus" | "Sections";
label: string;
hint: string;
run: () => void;
}
function SearchDialog({
open,
onClose,
onDivision,
onBureau,
}: {
open: boolean;
onClose: () => void;
onDivision: (i: number) => void;
onBureau: (id: string) => void;
}) {
const [query, setQuery] = useState("");
const [cursor, setCursor] = useState(0);
const inputRef = useRef<HTMLInputElement | null>(null);
const all = useMemo<SearchHit[]>(() => {
const jump = (id: string) => document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
return [
...DIVISIONS.map((d, i) => ({ id: `d${i}`, group: "Divisions" as const, label: d.name, hint: `${d.reach.toFixed(1)}M monthly`, run: () => { onDivision(i); jump("divisions"); } })),
...DIVISIONS.flatMap((d, i) => d.titles.map((t) => ({ id: `t${t}`, group: "Titles" as const, label: t, hint: d.short, run: () => { onDivision(i); jump("divisions"); } }))),
...BUREAUS.map((b) => ({ id: `b${b.id}`, group: "Bureaus" as const, label: b.city, hint: `${b.staff} journalists`, run: () => { onBureau(b.id); jump("bureaus"); } })),
{ id: "s-adv", group: "Sections" as const, label: "Plan a campaign", hint: "Advertise", run: () => jump("advertise") },
{ id: "s-news", group: "Sections" as const, label: "Latest stories", hint: "Newsroom", run: () => jump("newsroom") },
];
}, [onDivision, onBureau]);
const q = query.trim().toLowerCase();
const hits = q ? all.filter((h) => `${h.label} ${h.hint} ${h.group}`.toLowerCase().includes(q)) : all.filter((h) => h.group !== "Titles");
const safeCursor = Math.min(cursor, Math.max(0, hits.length - 1));
useEffect(() => {
if (open) inputRef.current?.focus();
}, [open]);
const choose = (hit: SearchHit | undefined) => {
if (!hit) return;
onClose();
setQuery("");
setCursor(0);
window.setTimeout(hit.run, 60);
};
const onKey = (e: ReactKeyboardEvent) => {
if (e.key === "ArrowDown") {
e.preventDefault();
setCursor((c) => Math.min(c + 1, hits.length - 1));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setCursor((c) => Math.max(c - 1, 0));
} else if (e.key === "Enter") {
e.preventDefault();
choose(hits[safeCursor]);
}
};
let lastGroup = "";
return (
<AnimatePresence>
{open && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={onClose}
className="fixed inset-0 z-50 flex items-start justify-center bg-black/70 px-4 pt-[12vh] backdrop-blur-md"
>
<motion.div
role="dialog"
aria-modal="true"
aria-label="Search Meridian"
initial={{ scale: 0.97, y: -10 }}
animate={{ scale: 1, y: 0 }}
exit={{ scale: 0.97, y: -10 }}
transition={{ duration: 0.2, ease: EASE }}
onClick={(e) => e.stopPropagation()}
className="w-full max-w-xl overflow-hidden rounded-2xl border border-white/15 bg-[#12131A] shadow-[0_30px_90px_rgba(0,0,0,0.8)]"
>
<div className="flex items-center gap-3 border-b border-white/10 px-4">
<Search className="size-4 text-white/40" />
<input
ref={inputRef}
value={query}
onChange={(e) => {
setQuery(e.target.value);
setCursor(0);
}}
onKeyDown={onKey}
placeholder="Search divisions, titles, bureaus…"
aria-label="Search"
className="h-14 flex-1 bg-transparent text-[15px] text-white placeholder:text-white/35 focus:outline-none"
/>
<button type="button" onClick={onClose} aria-label="Close search" className="rounded-md p-1 text-white/40 hover:text-white">
<X className="size-4" />
</button>
</div>
<div className="max-h-[50vh] overflow-y-auto p-2" role="listbox" aria-label="Results">
{hits.length === 0 && <div className="px-3 py-10 text-center text-[13px] text-white/45">No results for “{query}”</div>}
{hits.map((h, i) => {
const header = h.group !== lastGroup ? h.group : null;
lastGroup = h.group;
return (
<div key={h.id}>
{header && <div className="gm-mono px-3 pb-1.5 pt-3 text-[10.5px] uppercase tracking-[0.18em] text-white/35">{header}</div>}
<button
type="button"
role="option"
aria-selected={i === safeCursor}
onMouseMove={() => setCursor(i)}
onClick={() => choose(h)}
className={cn(
"flex w-full items-center justify-between rounded-lg px-3 py-2.5 text-left transition-colors",
i === safeCursor ? "bg-white/[0.07]" : "",
)}
>
<span className={cn("text-[14px]", h.group === "Titles" ? "gm-serif italic text-white" : "text-white")}>{h.label}</span>
<span className="flex items-center gap-2 text-[12px] text-white/40">
{h.hint}
{i === safeCursor && <CornerDownLeft className="size-3.5 text-white/60" />}
</span>
</button>
</div>
);
})}
</div>
<div className="gm-mono flex items-center justify-between border-t border-white/10 px-4 py-2.5 text-[10.5px] text-white/35">
<span className="inline-flex items-center gap-1.5">
<Command className="size-3" />K to toggle
</span>
<span>↑ ↓ to move · ↵ to open · esc to close</span>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
);
}
/* ─── Page ─────────────────────────────────────────────────────────────────── */
export function GlobalMediaPage() {
const [menuOpen, setMenuOpen] = useState(false);
const [searchOpen, setSearchOpen] = useState(false);
const [division, setDivision] = useState(0);
const [bureau, setBureau] = useState(HQ.id);
const [planRequest, setPlanRequest] = useState({ division: 0, n: 0 });
useEffect(() => {
const onKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === "k") {
e.preventDefault();
setSearchOpen((v) => !v);
} else if (e.key === "Escape") {
setSearchOpen(false);
setMenuOpen(false);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, []);
return (
<MotionConfig reducedMotion="user">
<div className="gm-root relative h-svh overflow-x-hidden overflow-y-auto [scrollbar-width:thin] selection:bg-[#2F54FF] selection:text-white">
<PageStyles />
<Hero onOpenSearch={() => setSearchOpen(true)} menuOpen={menuOpen} setMenuOpen={setMenuOpen} />
<MobileMenu open={menuOpen} onClose={() => setMenuOpen(false)} />
<main>
<MastheadStrip />
<ReachSection />
<DivisionsSection active={division} setActive={setDivision} onPlan={(i) => setPlanRequest((r) => ({ division: i, n: r.n + 1 }))} />
<BureausSection active={bureau} setActive={setBureau} />
<CampaignPlanner request={planRequest} />
<NewsroomSection />
<ClosingCta />
</main>
<Footer />
<SearchDialog open={searchOpen} onClose={() => setSearchOpen(false)} onDivision={setDivision} onBureau={setBureau} />
</div>
</MotionConfig>
);
}
export default GlobalMediaPage;// Land mask for the particle globe: 360x180 cells of 1 degree, row 0 at 90°N, column 0 at 180°W.
// One bit per cell (LSB first), base64. Generated from Natural Earth land-50m (public domain) via world-atlas.
export const LAND_W = 360;
export const LAND_H = 180;
export const LAND_MASK =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACAfwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA/P8/AGD//z8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAD8//+F/////3/5AAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAPAH8H/+//////8HAAAAAAAAADSAAQAAAB8AAAAAAAAAAAAAAAAAAAAAAAAAAPD//wf4//////8BAACAdT8AAAAAAAAAAP4AAAAAAAAAAAAAAAAAAAAAAAB4+Jzn//D//////38AAACAewMAAAAAAAAAAAAfAAAAAAAAAAAAAAAAAAAAAIA4BHAyO0D8/////38AAAAAPAoAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAFhAmPPVH4D//////z8AAAAAAAAAAAAA4AEAAAD8HwAAAAAAAAAAAAAAAAAAAAD/Az/fDwAA/P////8BAAAAAAAAAADABwAAwP//PwAA4FsAAAAAAAAAAAAAAAQMAAQQAAAA8P////8BAAAAAAAAAABwAAAA+P//FwAAAAAAAAAAAAAAAAAAAP8BxvMxfwAA8P///3cAAAAAAAAAAAAcAIDh//9/3IcLAAcAAAAAAAAAAAAAgN9/xy+8xwEA4P///zcAAAAAAAAAAAAGAJ7i//////8fAD4AAAAA/P//////////////////////////////////////////////////////////AADwTwAAAADwH/D4//8AwP7/fxEAAAAAANgHAAAAgN9/////////////DwAAAAD+//+Bvob/H9SBof8AwP7//w8AAAAAoP8PAAAAD9///////////////48//P//////////////////////////////HwAA/PyFAAECAAAAAAAAAAAAAAAA4P//////////////////////////////DwAA4A4AADAAAAAAAAAAAAAAAAAAAP7/////////////////////////////AQBg4AAAAAwAAAAAAAAAAAAAAAAADv//////////////////////////////AByADwAAAAAAAAAAAAAAAAAAAAAAwAD4///////////Pw/gHAP4PAMA/AACA//F//v////////////////////8fAAP4///////////BAGAHAP4HAAADAACw//z///////////////////////9/AID//////////38AQ4AJAPwDAAAAAAD8P/7/////////////////////f/5fAMD//////////z8AwA8AAPgDAAAAAAD+H/z/////////////////////Mf8BAID/7/z//////x8AwD8AAMAAAAAAAAT+P7z+//////////////////9/wG8AAAD8AwD//////x8AwD8IAAAAAAAAAAC+P2D///////////////////91cAAAAACACwD4/////38AwP8eAAAAAAAAgAGcH/T//////////////////wEAOAAAAACADgDw/////38AgP8/AAAAAAAA4AMAX/T//////////////////wAAfgAAAAAwAABA//////8PgP8/AAAAAAAAgAFwHv7/////////////////PwAAfwAAAAAAAABA//////8/gP//AAAAAAAAgAPwAv7/////////////////DwAAPwAAAIAAAAAA/P//////8///AwAAAAAAcAcgcv//////////////////HwAAHwAAAAAAAACA/P//////4///DwAAAAAAPA7o/////////////////////wEADwAAAAAAAAAA8f//////4///DwAAAAAAPD/+/////////////////////wUAAwAAAAAAAAAA8P//////////FwAAAAAAAB///////////////////////wUAAwAAAAAAAAAAkP//////////CAAAAAAAgMD//////////////////////wwAAAAAAAAAAAAAYP////////8xLAAAAAAAAPT//////////////////////wwAAAAAAAAAAAAAAP///////38PfAAAAAAAgP///////////////////////wQAAAAAAAAAAAAAAP3///////8HUAAAAAAAAPz/////////////////////fwQAAAAAAAAAAAAAAP////////+XAAAAAAAAAPz////3/B/+////////////PwgAAAAAAAAAAAAAAP////////9/AAAAAAAAAPj//f9j/A/+////////////HwAAAAAAAAAAAAAAAP////////8MAAAAAAAAAPj/+P8B/Mf/////////////DwQAAAAAAAAAAAAAAP///////z8AAAAAAAAAcPzH4/8B8I//////////////Bx4AAAAAAAAAAAAAAP///////x8AAAAAAAAA+H+gw/8AwA/+//////////9/AAQAAAAAAAAAAAAAAP///////x8AAAAAAAAA+D8Ajv/w4B/8//////////8/AAAAAAAAAAAAAAAAAP///////wMAAAAAAAAA+B8wuA/+/z/+/////////98fAAMAAAAAAAAAAAAAAP///////wMAAAAAAAAA+E8wEIf//x/+/////////2cOAAMAAAAAAAAAAAAAAP7//////wEAAAAAgAAA+A8AEM7//x/8/////////wMOAAMAAAAAAAAAAAAAAPz//////wAAAAAAAAAA+AcABoT//x/8/////////zcYgAEAAAAAAAAAAAAAAPz//////wAAAAAAAAAAQOB/AAA2/////////////x8Y8AEAAAAAAAAAAAAAAPj//////wAAAAAAAAAAQPh/AAAA/////////////w8c/AAAAAAAAAAAAAAAAPj/////fwAAAAAAAAAA4P8/AAAA/////////////w+EGwAAAAAAAAAAAAAAAMD/////HwAAAAAAAAAA8P9/AACA/////////////x/EAwAAAAAAAAAAAAAAAID/////BwAAAAAAAAAA+P//BweA/////////////x+AAAAAAAAAAAAAAAAAAID9////BwAAAAAAAAAA/P//D3/A/////////////x9AAAAAAAAAAAAAAAAAAAD5////BwAAAAAAAAAA/P//f////////////////x8AAAAAAAAAAAAAAAAAAAD2/x8CBgAAAAAAAAAA/P/////v/4///////////z8AAAAAAAAAAAAAAAAAAAD0/wcABgAAAAAAAAAA/v///////4///////////z8AAAAAAAAAAAAAAAAAAADu/wcADgAAAAAAAACA//////8//x/+/////////x8AAAAAAAAAAAAAAAAAAACY/wcADAAAAAAAAADA//////8//z/g/////////w8AAAAAAAAAAAAAAAAAAACQ/wcACAAAAAAAAADg//////9//j+Av////////w8AAAAAAAAAAAAAAAAAAAAQ/wMAAAAAAAAAAADg//////9//n8YgP///////yMAAAAAAAAAAAAAAAAAAABA/gMAAAAAAAAAAADw////////+P9/AP///////xEAAAAAAAAAAAAAAAAAAAAA/AMAPQAAAAAAAADw////////+P//APz/////PxAAAAAAAAAAAAAAAAAAAAAA+AMIYAAAAAAAAAD4////////+f9/AOz/B///BAAAAAAAAAAAAAAAAAAAAAAA/AccgAEAAAAAAADw////////8f9/AOD/B/5/BAAAAAAAAAAAAAAAAQAAAAAA+A8eADgAAAAAAADw////////4f8/AOD/Afw/BgAAAAAAAAAAAAAAAAAAAAAA8J8OAPwAAAAAAADw////////4/8fAOD/APw/AgAAAAAAAAAAAAAAAAAAAAAAgP8PAAAAAAAAAADw////////x/8HAOB/APh/ADAAAAAAAAAAAAAAAAAAAAAAAP4PAAAAAAAAAADw////////h/8BAOA/AMz/ADAAAAAAAAAAAAAAAAAAAAAAAID/AAAAAAAAAAD4////////j/8AAMAPAMD/ARAAAAAAAAAAAAAAAAAAAAAAAAD/AQAAAAAAAAD4////////nx8AAMAPAMD/ATAAAAAAAAAAAAAAAAAAAAAAAAD4AAAAAAAAAAD4////////vwcAAIAPAMD+AdAAAAAAAAAAAAAAAAAAAAAAAADgAAAAAAAAAAD4////////fwACAIAPAID8AQABAAAAAAAAAAAAAAAAAAAAAADAAEgAAAAAAADg////////f0AAAAAPAID4AUADAAAAAAAAAAAAAAAAAAAAAADAAe8TAAAAAADg/////////34AAAAPAEBwAAgAAAAAAAAAAAAAAAAAAAAAAAAAE+9/AAAAAADA/////////38AAAAXAEAgAAQCAAAAAAAAAAAAAAAAAAAAAAAAz///AAAAAACA/////////z8AAAASAMAAAIADAAAAAAAAAAAAAAAAAAAAAAAAyP//AQAAAAAA/////////z8AAAAwAIAAAAAHAAAAAAAAAAAAAAAAAAAAAAAAgP//AwAAAAAA/n///////x8AAAAwAAADAAMDAAAAAAAAAAAAAAAAAAAAAAAAgP//fwAAAAAA/A/+/////x8AAAAAAAgHgAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wAAAAAAEADk/////w8AAAAAADAGwAcAAAAAAAAAAAAAAAAAAAAAAAAAgP///wEAAAAAAADA/////wcAAAAAAGAG4AEAAAAAAAAAAAAAAAAAAAAAAAAAwP///wEAAAAAAADA/////wMAAAAAAMAM+AMQAAAAAAAAAAAAAAAAAAAAAAAA4P///wMAAAAAAADA/////wAAAAAAAIAL/gMQAAAAAAAAAAAAAAAAAAAAAAAA8P///wEAAAAAAADg////fwAAAAAAAIAH/uMQAAAAAAAAAAAAAAAAAAAAAAAA8P///w8AAAAAAADg////PwAAAAAAAAAX/gNIAQAAAAAAAAAAAAAAAAAAAAAA+P//f3cAAAAAAADg////PwAAAAAAAAAO/DkIAwAAAAAAAAAAAAAAAAAAAAAA+P///3sAAAAAAADA////HwAAAAAAAAB+/DkA8AEAAAAAAAAAAAAAAAAAAAAA+P////8fAAAAAACA////DwAAAAAAAAA8gChE/g8AAAAAAAAAAAAAAAAAAAAA+P////8/AAAAAAAA////BwAAAAAAAAA4AAAA8B8AAAAAAAAAAAAAAAAAAAAA+P//////AQAAAAAA////BwAAAAAAAAAwAAgAxD8NAAAAAAAAAAAAAAAAAAAA8P//////AQAAAAAA////BwAAAAAAAADAAQAAhP+AAAAAAAAAAAAAAAAAAAAA8P//////AQAAAAAA/v//BwAAAAAAAACAHwCAwH8AAAAAAAAAAAAAAAAAAAAA4P//////AQAAAAAA/v//BwAAAAAAAAAAgN8EAMcAAAAAAAAAAAAAAAAAAAAAwP//////AAAAAAAA/v//DwAAAAAAAAAAAAgBAIAFAAAAAAAAAAAAAAAAAAAAwP//////AAAAAAAA/P//DwAAAAAAAAAAAAAAAAAEAAAAAAAAAAAAAAAAAAAAgP////9/AAAAAAAA/P//DwAAAAAAAAAAAABAAQQgAAAAAAAAAAAAAAAAAAAAgP////8/AAAAAAAA/v//HyAAAAAAAAAAAACAHwQAAAAAgAAAAAAAAAAAAAAAAP////8fAAAAAAAA/v//HzAAAAAAAAAAAADADwwAAAAAAAAAAAAAAAAAAAAAAP////8fAAAAAAAA////HzAAAAAAAAAAAADMDxwAAAAAAAAAAAAAAAAAAAAAAP7///8fAAAAAAAA////HzwAAAAAAAAAAAD/Dx4AAAAAAAAAAAAAAAAA/P////////8fAAAAAAAA////Dz8AAAAAAAAAAID/vx4AAAiAAAAAQAAAAAAAAAAAAOD///8fAAAAAAAA////Ax8AAAAAAAAAAMD//z4AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA////AR8AAAAAAAAAAMD//z8AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8AAAAAAAAAAOD///8AAAAAAAAAAAAAAAAAAAAAAMD///8PAAAAAAAA/v9/AB8gAAAAAAAAAPz///8BAAEAAAAAAAAAAAAAAAAAAMD///8HAAAAAAAA/P9/gA8AAAAAAAAAgP////8BAAIAAAAAAAAAAAAAAAAAAMD///8DAAAAAAAA/P//gA8AAAAAAAAAwP////8DAAAAAAAAAAAAAAAAAAAAAMD//38AAAAAAAAA/P9/AA8AAAAAAAAAwP////8HAAAAAAAAAAAAAAAAAAAAAOD//x8AAAAAAAAA+P9/AAcAAAAAAAAA4P////8PAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAIAAAAAAAAAwP////8fAAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA+P8fAAAAAAAAAAAA4P////8fAAAAAAAAAAAAAAAAAAAAAOD//w8AAAAAAAAA+P8fAAAAAAAAAAAAwP////8/AAAAAAAAAAAAAAAAAAAAAOD//wcAAAAAAAAA8P8PAAAAAAAAAAAAgP////8/AAAAAAAAAAAAAAAAAAAAAOD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAPD//wMAAAAAAAAA4P8HAAAAAAAAAAAAgP////8fAAAAAAAAAAAAAAAAAAAAAPD//wAAAAAAAAAAwP8DAAAAAAAAAAAAAP////8fAAAAAAAAAAAAAAAAAAAAAOD//wAAAAAAAAAAwP8BAAAAAAAAAAAAAP8D/P8PAAAAAAAAAAAAAAAAAAAAAPD/fQAAAAAAAAAAwH8AAAAAAAAAAAAAAP8A2P8HAAAAAAAAAAAAAAAAAAAAAPD/OwAAAAAAAAAAgAAAAAAAAAAAAAAAAAcA6P8HAAAAAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwP8DAAACAAAAAAAAAAAAAAAAAPj/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAEAAAAAAAAAAAAAAAAAPz/BwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP8DAAAIAAAAAAAAAAAAAAAAAPz/AwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAGgAAAA4AAAAAAAAAAAAAAAAAPg/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAcAAAAAAAAAAAAAAAAAPw/AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAYAAAAAAAAAAAAAAAAAPwHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAALAAAAAAAAAAAAAAAAAPgPAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAIADAAAAAAAAAAAAAAAAAPgHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEAAAMABAAAAAAAAAAAAAAAAAPgHAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHAAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgAAAAAAAAAAAAAAAAAAP4BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADAAAAAAAAAAAAAAAAAAAPwDAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP0BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAP4AAAAAAAAAAAAAAAAAAAAAAAIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAH4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAAwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAJQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAANADAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAwAAAAAAAAAAAAAAAAAAAAAAAAAAABAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAMAAAAAAAAAAAAAAAAAMAfAAAAAAB/eADGPwAAQAAAAAAAAAAAAAAAAAAAAIADAAAAAAAAAAAAAAAAAOT/AQCA/////////w8AAAAAAAAAAAAAAAAAAAAAAAAWAAAAAAAAAAAAAAAA4P///wP4//////////8HAAAAAAAAAAAAAAAAAAAAALgfAAAAAAAAAAAAAABg8P///wH/////////////DwAAAAAAAAAAAAAAAAAAAGA/AAAAAAAAAAA4fwL+/////+H/////////////XwAAAAAAAAAAAAAAAAAAAPg+AAAAAAAA9/f///////////j//////////////38AAAAAAAAAAAAAAA4AAHh+AAAAAAAA////////////f/7//////////////x8AAAAAAAAAIAAAAP9/WPp/AAAAAADw/////////////////////////////wcAAAAAAAAAEeA3AP////9/AAAAAAD8////////////////////////////fwEAAAAAAID///////////8PAAAAAID+////////////////////////////PwAAAAAAAP7//////////z8AAAAAgP//////////////////////////////fwAAAADAH////////////wMAAAAA/P///////////////////////////////xwAAAAA////////////fAABAPwA/////////////////////////////////wcAAAAe4P///////////zAAAPwBwP//////////////////////////////DwAAAAAAwP///////////wD8wB8A+P//////////////////////////////HwAAAAAG/v////////////8HAAD8////////////////////////////////HwAAAAAA+P////////////8f8P///////////////////////////////////wEAAPjr/////////////////////////////////////////////////////38AgP8AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";