/* ==========================================================================
   Spectrum IT Hub — About hero world map
   --------------------------------------------------------------------------
   A dotted world map with four country pins and three arcs radiating from the
   UK. Ported from an Aceternity React component that is not usable here: it
   needs `dotted-map` (npm), `motion/react` and `next-themes`, and this site
   has no build step. Same call as the globe, the flip tiles and the link
   previews — rebuild the behaviour, drop the dependency.

   WHY THE DOTS ARE AN IMAGE AND NOT SVG NODES
   The land grid is 48,399 dots. As DOM that is 48,399 nodes on every page load,
   for something that never changes. It is baked to a 9 KB lossless WebP with
   alpha instead, generated offline by tools/make-worldmap-dots.py from Natural
   Earth land-50m. The reference does the same thing — its `getSVG()` output
   goes into an <img> data-URI.

   (This paragraph said "3,785 dots", "12.7 KB" and "countries-110m" for a long
   time. All three were wrong: the grid was rewritten to 0.6 deg and the source
   is land-50m. Numbers in comments rot — these are measured as of the dot
   radius fix below.)

   Only the pins and arcs are live SVG, because only they animate.

   PINS ARE COUNTRY CENTROIDS, NOT CITIES
   Deliberate, at the client's call: offices and branches move, countries do
   not. The map therefore claims "we work across these four countries", which
   is defensible, rather than naming a building.

   PROJECTION
   Equirectangular, lon -180..180 across 1600 units, lat 84..-56 down 622.
   The SVG shares that viewBox, so pin coordinates are just:
     x = (lon + 180) / 360 * 1600      y = (84 - lat) / 140 * 622
   Change the map bounds and these must change with them.
   ========================================================================== */

/* The width is not cosmetic, but the old note here was arithmetic on a grid
   that no longer exists (it said 0.75 deg and 3.12px). Measured now: the grid
   is 0.6 deg, so at the 1354px this renders to on a 1440px viewport the dots
   land 2.26px apart in CSS pixels — 4.5 device pixels on a 2x screen. That is
   enough to stay discrete ONLY because the dots themselves are 2.1px wide;
   see the note above .wm-dots. Narrow this a lot and the map turns into a
   halftone again. */
.wm {
  /* ONE CLOCK FOR THE WHOLE MAP. Everything that repeats is a multiple of this,
     which is what makes the parts read as one system instead of three loops
     that happen to share a frame. Before this the travelling pulse ran on 3.4s
     and the pin ping on 3s, so they drifted apart continuously and a pin never
     reliably answered the line that had just reached it. Change this one value
     and the arcs, the pins and the grid all re-time together. */
  --wm-cycle: 4.2s;

  position: absolute;
  top: 50%;
  left: 50%;
  width: min(1500px, 94%);
  transform: translate(-50%, -50%);
  pointer-events: none;          /* never steal a click from the hero copy */
}

.wm-inner { position: relative; }

/* --- Backdrop ------------------------------------------------------------
   The site's exact black, flat. Two earlier versions were rejected and both
   fairly: three stacked gradients was too much, and lifting toward Home's
   #0C1524 read blue - rgb(12,21,36) is +24 on blue-minus-red, which suits a
   blue globe but fights a gold map. There is no lift at all now; the grid is
   what keeps the frame from reading as a void, which is the right job for it. */
.wm-backdrop {
  position: absolute;
  inset: 0;
  background: #07080B;
}

/* --- Graticule -----------------------------------------------------------
   The coordinate grid the map is drawn on. Chosen over a starfield because
   stars are small light points on black and so are the map's 48,399 dots - the
   same visual family, competing. Lines cannot.

   FULL BLEED, BUT STILL THE MAP'S GRID. The lines are drawn far outside the
   1600x622 viewBox and `.wm-grid { overflow: visible }` lets them escape the
   frame, so
   they fill the hero while staying in the map's own coordinate system: where
   they cross the continents they sit on true meridians and parallels. A
   generic full-bleed grid would have been simpler and meant nothing.

   Gold, because it is the accent that belongs on this site's black. Held near
   5%: structure you feel rather than read. Past roughly 12% it starts taking
   attention from the arcs. */
.wm-grid {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;          /* lets the extended lines leave the frame */
  opacity: 0.5;               /* per-line alpha carries the real weight */

  /* Its own layer too, for the other half of the flicker. 72 <line> elements
     each run an infinite opacity animation, and SVG child opacity animates on
     the main thread — so without this, every one of those frames repainted the
     sticky hero's shared layer, taking the dot image down with it. Isolated,
     the grid repaints only itself. */
  transform: translateZ(0);
}

.wm-grid line {
  stroke: #E8B547;
  stroke-width: 1;
  vector-effect: non-scaling-stroke;   /* hairline at any hero width */
  opacity: 0.1;
  /* 12.6s is 3 x --wm-cycle, and the six groups below are phased at exactly
     12.6/6 = 2.1s. It was 11s with a 1.8s stagger, which is neither a multiple
     of the arc cycle nor an even division of its own period — six groups at
     1.8s span 10.8s of an 11s loop, so the phases crept every cycle. */
  animation: wmGridBreathe calc(var(--wm-cycle) * 3) ease-in-out infinite;
}

/* The equator sits a touch stronger, so the grid has one line of hierarchy
   rather than reading as uniform graph paper. */
.wm-grid .wm-grid-eq { opacity: 0.2; stroke-width: 1.2; }

/* Alive, without moving. Drifting the grid would break its alignment with the
   map, so the lines breathe in six staggered groups instead - the pattern
   never shifts, it just never sits completely still either. */
@keyframes wmGridBreathe {
  0%, 100% { opacity: 0.06; }
  50%      { opacity: 0.16; }
}

.wm-grid line:nth-child(6n+1) { animation-delay: 0.0s; }
.wm-grid line:nth-child(6n+2) { animation-delay: 2.1s; }
.wm-grid line:nth-child(6n+3) { animation-delay: 4.2s; }
.wm-grid line:nth-child(6n+4) { animation-delay: 6.3s; }
.wm-grid line:nth-child(6n+5) { animation-delay: 8.4s; }
.wm-grid line:nth-child(6n)   { animation-delay: 10.5s; }

/* --- The dot bed ---------------------------------------------------------
   Gold artwork rather than a white image tinted in CSS, so opacity can be
   tuned without shifting hue - and because a CSS mask would fail to a solid
   gold rectangle where it is unsupported. At white x 0.153 the dots measured
   1.47:1 against #07080B: invisible.
   To recolour, regenerate the asset; the generator takes one RGB tuple.

   OPACITY IS 0.85, AND IT USED TO BE 0.5, WHICH WAS HALF OF WHY THE MAP LOOKED
   DULL. The other half was in the artwork: the dots were drawn at radius 1.5 on
   a 5px pitch, so they nearly touched, and the browser's 1.108x downscale
   finished the job — 48% land fill, no visible gaps, a wash rather than a dot
   map. Fixing only one of the two would not have worked. The dots are radius
   1.05 now (20.6% fill, clear space around each one) and this carries them at
   0.85 so the contrast lost to the smaller dot comes back. Measured at true
   display scale, not judged from a screenshot. */
.wm-dots {
  display: block;
  width: 100%;
  height: auto;
  opacity: 1;

  /* THIS IS THE SCROLL FLICKER FIX, and it is not cargo-culted — measured.
     A 3000px image is laid out at 1353.59px: a FRACTIONAL width, because .wm
     is `min(1500px, 94%)`. Sitting un-promoted inside a `position: sticky`
     hero that repaints on every scroll frame, the browser re-samples that
     3000 -> 1353.59 downscale as the layer re-rasterises, and a half-pixel
     difference in where each dot lands between frames is exactly what reads as
     shimmer. 84 animations are running inside the same hero, so that layer is
     never idle and never gets to keep a cached raster.

     translateZ(0) gives the image its own compositor layer. It is static — it
     never animates — so it is rasterised ONCE at a fixed scale and merely
     composited from then on, whatever the hero does around it. */
  transform: translateZ(0);
  backface-visibility: hidden;
}

/* Shares the dot image's box exactly, so pin coordinates line up with it. */
.wm-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  overflow: visible;
}

/* --- Cursor spotlight ----------------------------------------------------
   The dot artwork is used as a MASK over a gradient that follows the pointer,
   so the map's own dots light up near the cursor. That is why it costs no
   extra DOM: one element, not 48,399 nodes doing proximity maths every frame.
   world-map.js writes --mx / --my; the fallback centres it.

   Guarded by @supports because without mask support this would render as a
   bare glowing rectangle over the map - a far worse failure than simply not
   having the effect. */
.wm-spot { display: none; }

@supports ((-webkit-mask-image: url(#m)) or (mask-image: url(#m))) {
  .wm-spot {
    display: block;
    position: absolute;
    inset: 0;
    pointer-events: none;
    opacity: 0;
    transition: opacity 420ms ease;
    /* THE CORE HAS TO GO PAST GOLD, and that is the whole trick now.

       This layer paints OVER the dots, so what you see in the pool is a blend
       of the gradient and the dot under it. The bed is `opacity: 1` gold —
       #FFC84F, (255, 200, 79) — and its red channel is already maxed. A GOLD
       glow on top of gold therefore has almost nothing left to lift: at 270px
       and 0.72 the pool composited to about (255, 226, 158) against a (255,
       200, 79) surround, which is a change in two channels only and read as
       dull, exactly as reported. Raising the alpha alone would not have fixed
       it either — the ceiling is the colour, not the opacity.

       So the core is near-white at full alpha. A lit dot goes to roughly
       (255, 251, 240) against the same gold surround: gold heated to white,
       which is what lit gold actually does, and a change the eye reads
       instantly. The falloff returns to gold so the pool's edge still belongs
       to the map rather than ending in a grey ring.

       The other half of "dull" was not brightness at all — the glow was landing
       up to 61px away from the cursor. See the note in world-map.js. */
    background: radial-gradient(circle 300px at var(--mx, 50%) var(--my, 50%),
                rgba(255, 251, 240, 1)    0%,
                rgba(255, 226, 158, 0.88) 26%,
                rgba(232, 181, 71, 0.52)  58%,
                rgba(232, 181, 71, 0)     78%);
    -webkit-mask-image: url("../../uploads/about-worldmap-dots.webp");
            mask-image: url("../../uploads/about-worldmap-dots.webp");
    -webkit-mask-size: 100% 100%;
            mask-size: 100% 100%;
    -webkit-mask-repeat: no-repeat;
            mask-repeat: no-repeat;
  }

  /* Only once the pointer is actually over the hero, so it does not sit lit in
     a corner before anyone has moved the mouse. */
  [data-parallax-hero][data-pointer] .wm-spot { opacity: 1; }
}

/* --- Arcs ----------------------------------------------------------------
   Red for the routes against the gold world: two brand colours doing two jobs,
   gold for where the world is, red for where Spectrum reaches.
   pathLength="1" normalises every path to 0-1, so one dasharray works for arcs
   of different real lengths and nothing has to be measured. */
.wm-arc {
  fill: none;
  stroke: #D32027;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  opacity: 0.75;
  animation: wmDraw 1.6s cubic-bezier(.22, 1, .36, 1) forwards;
}

/* Keyed on data-route, NOT :nth-of-type. nth-of-type counts by ELEMENT TYPE
   among siblings, and the arcs and pulses interleave as
   arc, pulse, arc, pulse, arc, pulse — so every <path> here is type `path`.
   `.wm-arc:nth-of-type(2)` therefore asked for "an arc that is also the 2nd
   path", which is a pulse, and matched nothing; `:nth-of-type(3)` landed on
   the SECOND arc; and the third arc matched no rule at all. Measured result
   was 0s / 0.7s / 0s — the US and India arcs drew together and the intended
   0 / 0.35 / 0.7 cascade never existed. Route numbers are 1 = US, 2 = UAE,
   3 = India, and they cannot drift with markup order. */
.wm-arc[data-route="2"] { animation-delay: 0.35s; }
.wm-arc[data-route="3"] { animation-delay: 0.7s; }

@keyframes wmDraw { to { stroke-dashoffset: 0; } }

/* THE TRAVELLING SWEEP — the whole line leaves the UK and reaches the far pin.
   It used to be a 5.5% comet (`stroke-dasharray: 0.055 0.945`), so what
   actually moved was a short white dash and the route itself never went
   anywhere. This is at the client's call: the LINE should travel, not a dot on
   it. `stroke-dasharray: 1` with the offset running 1 -> 0 draws the entire
   path from its start, and every arc's `d` begins at the UK pin (788.9, 133.3),
   so the sweep always departs from the hub and arrives at the destination.

   It rides on top of the red arc rather than replacing it: the route stays
   drawn, and the sweep is the signal running along it. Same stroke-width as
   the arc, so it lights the line rather than fattening it.

   Still dasharray rather than offset-path or animateMotion: no path data
   duplicated into CSS, no SMIL, and it respects prefers-reduced-motion like
   any other CSS animation. */
.wm-pulse {
  fill: none;
  stroke: #F7F6F3;
  stroke-width: 1.5;
  stroke-linecap: round;
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  opacity: 0;
  /* 2.4s, not the old 1.9s: the last arc does not finish drawing until 0.7s
     delay + 1.6s draw = 2.3s, so the first sweep used to set off along a route
     that was still being drawn. */
  animation: wmTravel var(--wm-cycle) cubic-bezier(.37, .01, .3, 1) infinite 2.4s;
}

/* 0.8s apart, so the three routes fire in the same order the arcs drew and
   never overlap their arrivals. Keyed on data-route for the reason above. */
.wm-pulse[data-route="2"] { animation-delay: 3.2s; }
.wm-pulse[data-route="3"] { animation-delay: 4.0s; }

/* 55% is the arrival, and it is load-bearing: the destination pin's ping is
   delayed by 0.55 x --wm-cycle so it answers exactly when the sweep lands.
   Move this and move the ping delays with it. */
/* THE BRIGHTNESS PEAKS ON ARRIVAL, and that is what keeps three overlapping
   sweeps from reading as a flash. The routes are 0.8s apart but each is in
   flight for about 2.3s, so all three ARE lit at once for roughly two seconds
   of every cycle — unavoidable without making the travel too fast to follow.
   Measured at the worst moment (t=5500ms) with a flat 0.95 plateau, all three
   sat at exactly 0.95 and the map lit up like a strobe. Ramping instead means
   the one that has just landed is the brightest thing on the map and the other
   two are visibly behind it, which reads as three signals in flight. */
@keyframes wmTravel {
  0%   { stroke-dashoffset: 1;    opacity: 0;    }
  6%   { stroke-dashoffset: 0.94; opacity: 0.45; }   /* leaving the UK */
  40%  {                          opacity: 0.68; }
  55%  { stroke-dashoffset: 0;    opacity: 0.95; }   /* arrived — peak */
  68%  { stroke-dashoffset: 0;    opacity: 0.62; }
  100% { stroke-dashoffset: 0;    opacity: 0;    }   /* fades, leaving the red route */
}

/* --- Pins ----------------------------------------------------------------
   pointer-events restored here only: the map as a whole stays click-through so
   the hero copy and CTAs underneath remain reachable. */
.wm-pin-group {
  pointer-events: auto;
}

/* THE HOVER TARGET.
   An invisible circle, because the pin itself is a 3.8px dot and the label
   starts 11px away from it — so the pointer had to find a tiny target, and
   sliding from the pin to its own label crossed dead space and dropped the
   hover. `pointer-events: all` makes it hit-testable with no fill at all. */
.wm-hit { fill: none; pointer-events: all; }

/* THE SCALE IS ON THE CIRCLES, NOT THE GROUP — this is what stopped the
   flicker. `transform-box: fill-box` resolves against the element's own
   bounding box, and a <g>'s bounding box INCLUDES ITS LABEL. Scaling the group
   therefore grew it about a centre far to the right of the pin and threw the
   pin up to 24.6px away from the cursor, against a 4.7px pin radius. The
   pointer fell off the target, the hover dropped, the pin snapped back under
   the pointer, and it oscillated. Each circle now scales about its own centre,
   so it grows exactly in place and cannot move out from under the pointer. */
.wm-pin, .wm-glow {
  transform-box: fill-box;
  transform-origin: center;
  transition: transform 300ms cubic-bezier(.16, 1, .3, 1);
}

.wm-pin-group:hover .wm-pin,
.wm-pin-group:focus-visible .wm-pin,
.wm-pin-group:hover .wm-glow,
.wm-pin-group:focus-visible .wm-glow { transform: scale(1.35); }

.wm-pin  { fill: #D32027; }

/* THE PING RUNS ON --wm-cycle, NOT ITS OWN 3s. That was the incoherence worth
   fixing: at 3s against the sweep's 3.4s the two loops drifted a beat apart
   every cycle, so a pin pinged whenever it happened to, never when the line
   reached it. Sharing the cycle means the phase set below holds forever. */
/* `opacity: 0` IS THE RESTING STATE, AND IT IS NOT DECORATION. An animation
   does not apply its 0% keyframe during its animation-delay, so without a base
   the ring renders at its own full opacity until its first run — a static red
   circle sitting on the pin. That was always true here, but the old delays
   topped out at 1.8s so nobody saw it; the arrival-locked delays below run to
   6.31s, which would have parked a visible ring on India for six seconds on
   every load. Deliberately not `animation-fill-mode: backwards`, which would
   hold the 0% keyframe (opacity 0.85) instead and be just as visible. */
.wm-ping { fill: none; stroke: #D32027; stroke-width: 1.2; transform-box: fill-box;
           transform-origin: center; opacity: 0;
           animation: wmPing var(--wm-cycle) ease-out infinite; }
.wm-glow { fill: #D32027; opacity: 0.22; filter: url(#wmGlow);
           transition: opacity 300ms ease, transform 300ms cubic-bezier(.16, 1, .3, 1); }

.wm-pin-group:hover .wm-glow { opacity: 0.5; }
.wm-pin-group.is-hub .wm-glow { opacity: 0.3; }

/* EACH PIN ANSWERS ITS OWN LINE. The numbers are not a stagger for its own
   sake — each is the moment that route's sweep arrives:

     departure = the .wm-pulse animation-delay      (2.4 / 3.2 / 4.0s)
     arrival   = departure + 0.55 x --wm-cycle      (+2.31s)

   so 4.71s, 5.51s, 6.31s. The hub is the exception: it pings on DEPARTURE, at
   2.4s, when the first sweep leaves it. Because every ping now shares
   --wm-cycle with the sweeps, these phases hold for as long as the page is
   open instead of drifting apart.

   Keyed on data-route, same as the arcs. These were correct as :nth-of-type —
   the four pin groups really are the only <g> children — but one more <g>
   anywhere in this SVG would have shifted every delay silently, which is the
   bug that did happen to the arcs above. */
.wm-pin-group.is-hub          .wm-ping { animation-delay: 2.4s;  }
.wm-pin-group[data-route="1"] .wm-ping { animation-delay: 4.71s; }
.wm-pin-group[data-route="2"] .wm-ping { animation-delay: 5.51s; }
.wm-pin-group[data-route="3"] .wm-ping { animation-delay: 6.31s; }

@keyframes wmPing {
  0%   { transform: scale(0.6); opacity: 0.85; }
  70%  { transform: scale(3.4); opacity: 0; }
  100% { transform: scale(3.4); opacity: 0; }
}

/* --- Labels --------------------------------------------------------------
   Real text, so a screen reader can read them and they stay crisp at any size.
   #8A9099 measures 6.23:1 on #07080B; the #7E8492 used elsewhere on the site
   fails AA at 4.27:1. */
.wm-label {
  font-family: "IBM Plex Mono", monospace;
  font-size: 15px;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  fill: #8A9099;
  transition: fill 300ms ease;
}

.wm-pin-group:hover .wm-label { fill: #F7F6F3; }

/* --- Height ---------------------------------------------------------------
   About's header is position: sticky and holds flow; Home's is fixed and holds
   none. Without this correction the hero renders one screen PLUS a header.

   91px is measured, not guessed:
       .hdr-bar    88px content
     + .hdr-shell   1px top border + 1px bottom border
     + .hdr         1px bottom border
     = 91px
   header.css's own note confirms the footprint does not change on scroll:
   "0 + 88 at rest, 12 + 64 + 12 scrolled". If any of those borders change,
   this number has to change with them.

   Scoped above 760px on purpose. Below that the header drops to 76px, but the
   hero is unpinned by responsive.css and sized by its content anyway, so the
   calc must not apply or it would fight that. */
@media (min-width: 761px) {
  [data-hero-inset] { height: calc(100vh - 91px) !important; }
}

@media (max-width: 1024px) {
  .wm { width: 96%; }
  .wm-label { font-size: 17px; }
}

@media (max-width: 760px) {
  .wm { opacity: 0.75; }
  .wm-dots { opacity: 0.65; }   /* held in proportion to the desktop bed (was 0.32 against 0.5) */
  .wm-label { display: none; }
  .wm-pin-group { pointer-events: none; }   /* no hover on touch */
}

/* --- Reduced motion ------------------------------------------------------
   Show the finished state: arcs drawn, no travelling pulse, no ping. */
@media (prefers-reduced-motion: reduce) {
  .wm-arc   { animation: none; stroke-dashoffset: 0; }
  .wm-pulse { animation: none; opacity: 0; }
  .wm-ping  { animation: none; opacity: 0.2; }
  .wm-pin, .wm-glow { transition: none; }
  .wm-grid line { animation: none; opacity: 0.1; }
}
