A raw bearing (0-359.999°) needs to become a label - N, NNE, NE, ... - for d3.easygraph's compassPoint16/compassPoint8/compassPoint4 utilities, using the 16-, 8-, and 4-point compass rose naming. Simple in principle - divide the circle into N equal sectors and see which one the bearing falls in - but getting the sector boundaries right, and reconciling three different resolutions cleanly, turned into a small, purely-for-fun math detour.

The naive approach - floor(direction / sectorWidth) - puts a sector's boundary exactly at 0°/N, splitting bearings just west and just east of due north into two different sectors instead of both reading "N". The fix is a half-sector offset before flooring, so each sector is centered on its own label: sector k of an N-point rose spans [k * 360/N - 180/N, k * 360/N + 180/N). That's exactly what the three functions do, each with its own sector width (22.5°, 45°, 90°) and half-width offset.

It's tempting to compute only the finest (16-point) index and derive the coarser 8- and 4-point indices from it with a bitmask - cheaper than three independent formulas. It doesn't work in general. An even-numbered 16-sector sits exactly inside one 8-sector, so its index carries enough information. An odd-numbered 16-sector straddles an 8-point boundary at its own center - the same 16-index can come from either of its two neighboring 8-sectors, and that information is already gone once the bearing has been reduced to a 16-index. No formula of the 16-index alone, bitwise or otherwise, can recover it.

The half-sector offset for the finest resolution in play (16 sectors, 22.5° wide) is 11.25° - exactly one 32nd of a circle. Dividing the circle into 32 raw, uncentered segments first gives a common fine-grained grid whose boundaries line up exactly with every centered sector boundary at all three resolutions, since 11.25 evenly divides 22.5, 45, and 90. Once a bearing is placed into one of the 32 segments, recovering each resolution's centered index is just a shift and a mask - no bearing math, no per-resolution formula, and (unlike deriving 8 from 16) no lost information, because 32 is finer than all three target resolutions rather than coarser:

_compassPoints = [
  'N', 'NNE', 'NE', 'ENE', 'E', 'ESE', 'SE', 'SSE',
  'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'
]

segment32(direction) = floor((direction + 360) / 11.25) & 0x1f

index16 = ((segment32 + 1) >> 1) & 0x0f
index8  = ((segment32 + 2) >> 1) & 0x0e
index4  = ((segment32 + 4) >> 1) & 0x0c

The shift amount for each resolution is that resolution's own half-sector offset, measured in 32nds: 11.25° (1 segment) for the 16-point rose, 22.5° (2 segments) for 8-point, 45° (4 segments) for 4-point. Verified exhaustively against the original per-resolution formulas at 0.01° resolution across -720° to 720° - zero mismatches.

Three concentric rings (16/8/4 sectors) quantized directly with centered per-resolution formulas, with 32 radial guide lines at 11.25-degree spacing.

Direct, centered formulas - one per resolution.

The same three rings, this time each rotated by its own centering offset - outer 1/32, middle 2/32, inner 4/32 of a circle - with the same 32 radial guide lines.

Same rings, each rotated by its own centering offset (1/32, 2/32, 4/32 of a circle).

Both images alternate black (odd sector) / white (even sector) per ring, sector 0/N centered at the top. Rotating a centered sector by exactly half its own width cancels the centering - [center - half, center + half) collapses back to the plain, edge-at-0 [k * width, (k + 1) * width) - so the second image is actually the naive, uncentered quantization for all three resolutions, just drawn with the same 32 guide lines for comparison.

That makes the second image's rings reduce cleanly into each other, unlike the first: since 22.5°, 45°, and 90° are exact multiples of 11.25° (2, 4, and 8 segments), masking one bit off the outer (16-sector) ring's index gives exactly the middle (8-sector) ring's index, and masking two bits gives the inner (4-sector) ring's - outer >> 1 == middle and outer >> 2 == inner, holding exactly everywhere. That's the flip side of "One Resolution Doesn't Reduce Cleanly To Another" above: the naive, uncentered quantization reduces by simple bit-masking precisely because it throws away the centering that makes the labels correct in the first place.

Both images draw sector 0/N at the top rather than at 0°/east, matplotlib's own default - because a compass bearing (north = 0°, clockwise) and a standard math angle (east/+x axis = 0°, counterclockwise) are both just angles around the same circle, related by a fixed reflection rather than a rotation. Subtracting either one from 90° gives the other:

mathAngle = 90 - bearing
bearing   = 90 - mathAngle

The same formula both ways, since swapping which one you start from just reflects across the same 45° line (NE/SW) again. In radians, feeding a bearing into something that expects a math angle looks like angle = (bearingDegrees - 90) * Math.PI / 180 - exactly what d3.easygraph.scatter's arrows feature expects for its angle field (see the d3-easygraph README - scatter deliberately stays geography-agnostic, so a caller with compass-bearing data, like the wind arrows on weather's map, converts before handing points to it).

METAR reports, and the meteorological convention generally, give wind direction as the bearing the wind is blowing from, not where it's headed - a reported 270° means a wind out of the west, blowing east. That's the right convention for a pilot lining up with a runway, but it reads backwards for a vector meant to show where the wind is actually going. Every place on this site that turns a stored bearing into a vector - the sin/cos decomposition below, and the wind arrow overlay on the weather map - flips it first:

towardDeg = fromDeg + 180

That's the whole fix - add 180°, nothing more - and it's applied once, up front, to the raw bearing, before any of the averaging or decomposition below ever runs. Neither atan2 nor the sin/cos decomposition cares which convention fed it; the flip just isn't baked into either formula, so it has to happen first.

A direction is an angle, not a plain number, so the usual sum / count average breaks right where it matters most: two readings of 350° and 10° - both nearly due north - naively average to (350 + 10) / 2 = 180°, due south, exactly backwards. The fix is to stop averaging the angle directly and average it as a 2D unit vector instead:

ns = cos(d)   // north/south component
ew = sin(d)   // east/west component

meanDirection = atan2(sum(ew), sum(ns))

ns is cos and ew is sin - the reverse of the usual x = cos(θ), y = sin(θ) pairing, for the same reason covered above: a compass bearing measures from north (0°), not east, so north/south gets cos and east/west gets sin, the opposite of a standard math angle.

Each direction becomes a point on the unit circle; combining north/south and east/west with atan2 gives a mean that wraps correctly (350° and 10° now average to 0°, as expected). No need to divide by the count first - atan2(y, x) is just atan(y / x) with the division-by-zero case handled and the correct quadrant picked from the sign of each argument, and scaling both arguments by the same count doesn't change their ratio, so the raw sums work as well as the means would. The by-product is as useful as the average itself: cos(d)/sin(d) is exactly the north-south/east-west decomposition weather and sensor data already needs for plotting - this site's report pages draw wind not as a raw angle but as a vector in that same NS/EW space, and bucket-series.js (the hourly/daily aggregation behind weather's and sensors' report ribbons) uses this same circular mean for every bearing-named sensor, alongside a plain sum / count average for everything else.

The two live report pipelines use the decomposition slightly differently, matching what each actually needs from it. epw/report.php converts wind direction to sin/cos columns before aggregating and keeps the resulting vector's own length - sqrt(mean(ns)² + mean(ew)²) - as a free consistency signal: a full-length vector means the wind held steady, a short one means it was averaged from readings scattered all over the compass, not that it was calm. weather/sensors instead force the averaged vector back onto the unit circle, since their consumers want a single bearing to display, not a confidence magnitude.