mutable shipNumberStatus = "loading"
mutable tripDateStatus = "loading"
shipNumber = {
const pageURL = new URL(window.location.href)
const shipNum = pageURL.searchParams.get("shipNumber")
if (shipNum == null) {
console.log("Ship num missing")
mutable shipNumberStatus = "error-shipnum-missing"
return null
}
// imo and mmsi ship numbers are 7 and 9 digits respectively
const isIMONumber = /^[0-9]{7}$/
const isMMSINumber = /^[0-9]{9}$/
if (!(isIMONumber.test(shipNum) || isMMSINumber.test(shipNum))) {
mutable shipNumberStatus = "error-shipnum-invalid"
return null
}
mutable shipNumberStatus = "ok"
return shipNum
}
tripDate = {
const pageURL = new URL(window.location.href)
const tripDate = pageURL.searchParams.get("date")
if (tripDate == null) {
console.log("Date missing")
mutable tripDateStatus = "error-date-missing"
return null
}
const isDate = /^[0-9]{8}$/
if (!(isDate.test(tripDate))) {
mutable tripDateStatus = "error-date-invalid"
return null
}
mutable tripDateStatus = "ok"
return tripDate
}
// unblock page if ship number and date are both available
unblockPage = {
if (shipNumberStatus == "ok" && tripDateStatus == "ok") {
document.title = `Ship ${shipNumber} on ${tripDate} - CounterCurrent`
document
.querySelectorAll(".results-required")
.forEach(el => el.classList.add("results-found"))
}
}
FA = FileAttachment
response = FA(`https://d1grzbvul8484a.cloudfront.net/output-shipday/${tripDate}/${shipNumber}.json`).json()
greatCircle = [response.gc_start, response.gc_end].map(d => ({
...d,
dateTimeString: prettifyDateTime(d.time)
}))
gcPoints = makePointCollection(greatCircle)
gcLine = makeLineString(greatCircle)
gcBounds = makeBounds(greatCircle)
optimisedLine = makeLineString(response.routes.optimised.waypoints.map(d => ({
"lon": d.lon,
"lat": d.lat
})))
mapboxKey = "pk.eyJ1IjoiY291bnRlcmN1cnJlbnQiLCJhIjoiY21ha2w1cWVqMWNrajJqb2twODAwcjJ1ZiJ9.OtD5qlGMlErG6z-vP-9GLw"
mapboxStyle = "countercurrent/cmaooq4ll001p01rf3m7f2imc"
mapboxgl = {
const gl = await require("mapbox-gl@3.12")
if (!gl.accessToken) {
gl.accessToken = mapboxKey
const href = await require.resolve("mapbox-gl@3.12/dist/mapbox-gl.css")
document.head.appendChild(html`<link href=${href} rel=stylesheet>`)
}
return gl
}
// load plot
Plot = require("@observablehq/plot@0.6.17")
// instantiate map
viewof map = {
let container = html`<div />`
yield container
let map = new mapboxgl.Map({
container,
center: [0, 0],
zoom: 10,
style: `mapbox://styles/${mapboxStyle}`,
scrollZoom: false
})
map.on("load", () => {
container.value = map
container.dispatchEvent(new CustomEvent("input"))
})
}
mapLoadTime = {
map.addSource("gc-line-source", {
type: "geojson",
data: gcLine,
lineMetrics: true
})
map.addLayer({
id: "gc-line-layer",
type: "line",
source: "gc-line-source",
layout: {
"line-cap": "square",
"line-join": "round",
},
paint: {
"line-color": "rgba(0, 0, 0, 0)", // overridden by line-gradient
"line-width": 5,
"line-dasharray": [1, 2]
}
})
map.addSource("optimised-line-source", {
type: "geojson",
data: optimisedLine,
lineMetrics: true
})
map.addLayer({
id: "optimised-line-layer",
type: "line",
source: "optimised-line-source",
layout: {
"line-cap": "square",
"line-join": "round",
},
paint: {
"line-color": "rgba(0, 0, 0, 0)", // overridden by line-gradient
"line-width": 5,
// "line-dasharray": [1, 2]
}
})
// add points for start and finish
map.addSource("gc-point-source", {
type: "geojson",
data: gcPoints
})
map.addLayer({
id: "gc-point-layer",
type: "circle",
source: "gc-point-source",
paint: {
"circle-color": "orange",
"circle-radius": 8,
}
})
map.addLayer({
id: "gc-label-layer",
type: "symbol",
source: "gc-point-source",
layout: {
"text-field": ["get", "dateTimeString"],
"text-font": ["IBM Plex Sans Condensed Bold"],
"text-size": 12,
"text-justify": "left",
"text-anchor": "left",
},
paint: {
"text-color": "orange",
"text-halo-color": "#092030",
"text-halo-width": 2,
"text-translate": [15, 5]
}
})
// finally, add text labels on points
return Date.now()
}
// fit map to routes whenevr window is resized
adjustBounds = {
map.fitBounds(gcBounds, {
animate: false,
padding: {
left: 40,
right: width > 767.98 ? width * 0.45 : 40,
top: 100,
bottom: width > 767.98 ? 250 : 450
}
})
}
function makePointCollection(arr, xAcc = d => d.lon, yAcc = d => d.lat, props = {}) {
const coordArray = arr.map(d => ({
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [xAcc(d), yAcc(d)]
},
"properties": d
}))
return ({
"type": "FeatureCollection",
"features": coordArray
})
}
function makeLineString(arr, xAcc = d => d.lon, yAcc = d => d.lat) {
const coordArray = arr.map(d => [xAcc(d), yAcc(d)])
return ({
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": {
"type": "LineString",
"coordinates": coordArray
}
}
]
})
}
// TODO - reexamine this logic for anti-meridian crossing!
function makeBounds(arr, xAcc = d => d.lon, yAcc = d => d.lat) {
return [
[
Math.min(...arr.map(d => xAcc(d))),
Math.min(...arr.map(d => yAcc(d)))
],
[
Math.max(...arr.map(d => xAcc(d))),
Math.max(...arr.map(d => yAcc(d)))
],
]
}
function animateLineGradient({
layer,
colour,
startTime,
duration,
delay
} = {}) {
// TODO - use d3.scaleLinear()
const aniProgress = Math.min((now - (startTime + delay)) / duration, 1)
map.setPaintProperty(
layer,
"line-gradient",
[
"step", ["line-progress"],
colour,
aniProgress,
"rgba(0, 0, 0, 0)",
]
)
return aniProgress
}
function getMiddleCurrent(arr) {
if (!Array.isArray(arr)) {
console.error("getMiddleCurrent expects an array, not: ", arr)
return null
}
const middle = Math.floor(arr.length / 2)
return arr[middle] < -90 ? NaN : arr[middle]
}
function getCurrentAtDistance(arr, dist, cellWidth = 9260) {
if (!Array.isArray(arr)) {
console.error("getCurrentAtDistance expects an array, not: ", arr)
return null
}
const indexFromStart = Math.floor((arr.length / 2) - (dist / cellWidth))
return arr[indexFromStart] < -90 ? NaN : arr[indexFromStart]
}
vectorLength = (along, cross) => Math.hypot(along, cross)
vectorAngle = (along, cross) => Math.atan2(along, -cross) * 180 / Math.PI
function accumulate(arr, i, accessor = d => d) {
return arr.slice(0, i + 1).reduce((acc, curr) => acc + accessor(curr), 0)
}
// prettifyDate: return formatted date
function prettifyDate(dt) {
const dateObj = new Date(dt)
const thisYear = new Date(Date.now()).getFullYear()
const wasThisYear = dateObj.getFullYear() == thisYear
// format with date and month (and year, if the date isn't this year)
const formatter = wasThisYear ?
new Intl.DateTimeFormat("en-US", { month: "short", day: "numeric" }) :
new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric"
})
return formatter.format(dateObj)
}
function prettifyDateTime(dt) {
const dateObj = new Date(dt)
// format with date and month (and year, if the date isn't this year)
const dateFormatter = new Intl.DateTimeFormat("en-US", {
weekday: "short",
month: "short",
day: "numeric",
})
const timeFormatter = new Intl.DateTimeFormat("en-US", {
hour: "numeric",
minute: "numeric",
timeZoneName: "short"
})
return dateFormatter.format(dateObj).toUpperCase() + "\n" +
timeFormatter.format(dateObj).toUpperCase()
}
animateGCProg = animateLineGradient({
layer: "gc-line-layer",
colour: "rgba(255, 255, 255, 0.5)",
startTime: mapLoadTime,
duration: 1500,
delay: 0
})
animateOptProg = animateLineGradient({
layer: "optimised-line-layer",
colour: "orange",
startTime: mapLoadTime,
duration: 1500,
delay: 500
})
land = FileAttachment("/assets/ne_110m_land.json").json()
Plot.plot({
projection: {
type: "orthographic",
rotate: [-response.gc_start.lon, 0]
},
marks: [
Plot.geo(land, {
fill: "#ffffff44"
}),
Plot.geo(gcLine, {
stroke: "orange",
strokeWidth: 1
}),
Plot.geo(gcPoints, {
fill: "orange",
r: 2
}),
Plot.sphere()
],
width: 100,
marginBottom: 10,
style: {
fontFamily: "Inter"
}
})
response?.shipNumber && shipNumberStatus == "ok" ?
md`${response["Ship Name"] || "Ship " + response.shipNumber}` :
md``
shipNumberStatus == "error-shipnum-missing" ?
md`**Error:** ship number missing (but we're not using it yet anyway)` :
md``
shipNumberStatus == "error-shipnum-invalid" ?
md`**Error:** ship number should be a valid 9-digit MMSI number or 7-digit IMO number` :
md``
headlineFuelBurn = gcMaxFuel ?
`This ship burned <span class="key-stat">${d3.format(".3s")(gcMaxFuel)} tonnes</span> of fuel on ${prettifyDate(response.gc_start.time)}.` :
``
headlineFuelSaving = fuelSavingPct && fuelSavingPct > 0 ?
` Save <span class="key-stat">${d3.format(".3")(gcMaxFuel - optMaxFuel)} tonnes</span> with our forecasts.` :
``
html`<h1> ${headlineFuelBurn}${headlineFuelSaving}</h1>`
CounterCurrent uses hyperlocal ocean forecasts to find the most fuel efficient route — with no change in arrival time.
shipNamePhrase = response["Ship Name"] ? response["Ship Name"] : "This ship"
md`## ${shipNamePhrase} could have saved <span class="key-stat">${d3.format(".1%")(fuelSavingPct)} fuel</span> over ${d3.format(".0f")(tripTime)} hours.`
md`That translates to <span class="key-stat">${"US$" + d3.format(".2s")(costSaving)}</span> and <span class="key-stat">${d3.format(".3s")(emissionsSaving)} tonnes</span> in greenhouse gas emissions.`
optimalRoute = response.routes.optimised.waypoints
.map((d, i) => ({
...d,
index: i,
alongDistNauticalMiles: i * 5,
type: "Optimal",
progressFrac: (i / response.routes.optimised.waypoints.length),
alongCurrentColumn:
response.oceancurrents.copernicus.along_track.map(d => d[i]),
crossCurrentColumn:
response.oceancurrents.copernicus.cross_track.map(d => d[i])
}))
.map((d, i, arr) => ({
...d,
cumTime: accumulate(arr, i, d => d.delta_time),
cumDeviation: accumulate(arr, i, d => d.delta_crossdistance),
}))
.map((d, i) => ({
...d,
alongCurrent: getCurrentAtDistance(d.alongCurrentColumn, d.cumDeviation),
crossCurrent: getCurrentAtDistance(d.crossCurrentColumn, d.cumDeviation),
}))
gcRoute = response.routes.great_circle.waypoints
.map((d, i) => ({
...d,
index: i,
type: "Great Circle",
progressFrac: (i / response.routes.great_circle.waypoints.length),
alongDistNauticalMiles: i * 5,
alongCurrentColumn:
response.oceancurrents.copernicus.along_track.map(d => d[i]),
crossCurrentColumn:
response.oceancurrents.copernicus.cross_track.map(d => d[i])
}))
.map((d, i, arr) => ({
...d,
cumTime: accumulate(arr, i, d => d.delta_time),
cumDeviation: accumulate(arr, i, d => d.delta_crossdistance),
}))
.map((d, i) => ({
...d,
alongCurrent: getMiddleCurrent(d.alongCurrentColumn),
crossCurrent: getMiddleCurrent(d.crossCurrentColumn),
}))
// pivot gc and optimal routes wider for visualisation
routeComparison = optimalRoute.map((d, i) => ({
...d,
index: d.index,
progressFrac: d.progressFrac,
optCumDeviation: d.cumDeviation,
fuelSaving: gcRoute[i].fuel_t - d.fuel_t,
emissionsSaving: gcRoute[i].emissions_t - d.emissions_t,
costSaving: gcRoute[i].cost_usd - d.cost_usd,
optAlongCurrent: d.alongCurrent,
optCrossCurrent: d.crossCurrent,
gcAlongCurrent: gcRoute[i].alongCurrent,
gcCrossCurrent: gcRoute[i].crossCurrent,
alongCurrentDiff: gcRoute[i].alongCurrent - d.alongCurrent,
crossCurrentDiff: gcRoute[i].crossCurrent - d.crossCurrent,
})).map((d, i) => ({
...d,
currentDiffLength: vectorLength(d.alongCurrentDiff, d.crossCurrentDiff),
currentDiffAngle: vectorAngle(d.alongCurrentDiff, d.crossCurrentDiff),
optHeading: vectorAngle(9260, -d.delta_crossdistance)
})).map((d, i) => ({
...d,
optHeadingVsCurrentDiff: d.currentDiffAngle - d.optHeading
})).map((d, i, arr) => ({
...d,
fuelSavingCum: accumulate(arr, i, d => d.fuelSaving),
emissionsSavingCum: accumulate(arr, i, d => d.emissionsSaving),
costSavingCum: accumulate(arr, i, d => d.costSaving),
}))
// combine routes and calculate fuel savings for hero stats
gcMaxFuel = gcRoute.map(d => d.fuel_t).reduce((sum, a) => sum + a, 0)
optMaxFuel = optimalRoute.map(d => d.fuel_t).reduce((sum, a) => sum + a, 0)
emissionsSaving = routeComparison.slice(-1).map(d => d.emissionsSavingCum)
costSaving = routeComparison.slice(-1).map(d => d.costSavingCum)
fuelSavingPct = 1 - (optMaxFuel / gcMaxFuel)
furthestWaypoint =
optimalRoute
.toSorted((a, b) => Math.abs(b.cumDeviation) - Math.abs(a.cumDeviation))
.slice(0, 1)
tripTime = (new Date(response.gc_end.time) - new Date(response.gc_start.time)) / (60 * 60 * 1000)
greatCircleDistance = response.routes.great_circle.waypoints.length * 5
deviationFormat = d3.format(".2s")
viewof hudPrimaryPlot = Plot.plot({
marks: [
Plot.ruleY([0], {
stroke: "#333333",
strokeWidth: 1.5,
strokeDasharray: "2",
}),
Plot.areaY(routeComparison, {
x: "alongDistNauticalMiles",
y: "optCumDeviation",
fill: "#1a65b0",
fillOpacity: 0.25,
}),
Plot.line(routeComparison, {
x: "alongDistNauticalMiles",
y: "optCumDeviation",
stroke: "#1a65b0",
}),
/* currents */
width > 600 ?
Plot.vector(routeComparison, {
x: "alongDistNauticalMiles",
y: d => d.optCumDeviation * 1.5,
length: d => vectorLength(d.alongCurrentDiff, d.crossCurrentDiff),
rotate: d => vectorAngle(d.alongCurrentDiff, d.crossCurrentDiff),
stroke: d => d.fuelSaving > 0 ? "green" : "red"
}) :
null,
// Plot.text(routeComparison, Plot.pointerX({
// x: "alongDistNauticalMiles",
// y: d => d.optCumDeviation * 1.98,
// text: d => d3.format(".2s")(vectorLength(d.alongCurrentDiff, d.crossCurrentDiff)) + "m/s",
// stroke: d => d.fuelSaving > 0 ? "green" : "red",
// fontSize: 16,
// fontWeight: "normal"
// })),
/* text annotation */
Plot.text(furthestWaypoint, {
x: "alongDistNauticalMiles",
y: d => d.cumDeviation * -0.7,
text: d => `We recommend that this ship deviate up to ${deviationFormat(Math.abs(d.cumDeviation) / 1852) + "nm"} in order to benefit from ocean currents.`,
lineWidth: 18,
}),
Plot.ruleX(furthestWaypoint, {
x: "alongDistNauticalMiles",
y1: d => d.cumDeviation * 0.9,
y2: d => d.cumDeviation * -0.2,
strokeDasharray: "2 2"
}),
/* tips */
Plot.ruleX(optimalRoute, Plot.pointerX({
x: "alongDistNauticalMiles",
y1: d => 0,
y2: "cumDeviation",
stroke: "#1a65b0",
})),
Plot.dot(optimalRoute, Plot.pointerX({
x: "alongDistNauticalMiles",
y: "cumDeviation",
stroke: "#333333",
fill: "white"
})),
Plot.dot(optimalRoute, Plot.pointerX({
x: "alongDistNauticalMiles",
y: "cumDeviation",
stroke: "#1a65b0",
fill: "white"
})),
Plot.text(optimalRoute, Plot.pointerX({
x: "alongDistNauticalMiles",
y: "cumDeviation",
stroke: "#1a65b0",
fill: "white",
text: d => deviationFormat(Math.abs(d.cumDeviation) / 1852) + "nm",
dy: 20
})),
/* labels */
Plot.text([`DIRECT ROUTE: ${greatCircleDistance} NM`], {
fontWeight: "bold",
fill: "#1a65b0",
x: greatCircleDistance / 2,
y: 0,
textAnchor: "center",
// lineAnchor: "bottom",
dy: -10
}),
Plot.text(gcRoute, Plot.selectFirst({
x: "alongDistNauticalMiles",
y: 0,
text: d => "START",
textAnchor: "start",
dy: -10,
fontWeight: "bold",
fill: "black",
// TODO - check first/last values before anchoring text against axis
})),
Plot.text(gcRoute, Plot.selectLast({
x: "alongDistNauticalMiles",
y: 0,
text: d => "END",
textAnchor: "end",
dx: -5,
dy: -10,
fontWeight: "bold",
fill: "black",
// TODO - check first/last values before anchoring text against axis
})),
],
x: {
label: null,
tickFormat: d => d3.format(".0f")(d) + "nm"
},
y: {
reverse: true,
label: null,
axis: null
},
length: {
range: [0, 15],
},
rotate: {
range: [0, 360]
},
width: width,
height: 275,
insetBottom: 40,
insetRight: 20,
marginLeft: 100,
marginTop: 50,
style: {
fontSize: 16,
fontFamily: "Inter"
}
})
viewof hudSecondarySelection = Inputs.radio(
new Map([
["Fuel", "fuel"],
["Emissions", "emissions"],
["Cost", "cost"]
]),
{
value: "fuel"
})
viewof accumulateSavings = Inputs.toggle({
value: true,
label: html`<span class="small text-muted">Accumulate savings over trip</span>`
})
secondaryPlotColBase = ({
"fuel": "fuelSaving",
"emissions": "emissionsSaving",
"cost": "costSaving"
})[hudSecondarySelection]
secondaryPlotFormat = ({
"fuel": d => d3.format(".2s")(d) + "T",
"fuel-pct": ".1%",
"emissions": d => d3.format(".2s")(d) + "T CO2e",
"cost": d => "US$" + d3.format(".2s")(d)
})[hudSecondarySelection]
secondaryPlotTitle = ({
"fuel": "Tonnes of fuel saved",
"fuel-pct": "Percentage of fuel saved",
"emissions": "Greenhouse gas emissions saved",
"cost": "Fuel cost saved"
})[hudSecondarySelection]
secondaryPlotCol = accumulateSavings ?
secondaryPlotColBase + "Cum" :
secondaryPlotColBase
secondaryPlotSubtitle = accumulateSavings ?
`Accumulated over ${d3.format(".0f")(tripTime)} hour trip` :
"Saved on each leg"
Plot.plot({
marks: [
Plot.ruleY([0], {
stroke: "#333333",
strokeWidth: 1.5,
strokeDasharray: "2",
}),
Plot.differenceY(routeComparison, {
x: "alongDistNauticalMiles",
y: secondaryPlotCol,
positiveFill: "green",
negativeFill: "red",
fillOpacity: 0.3,
strokeOpacity: 0.9,
// tip: true
}),
/* labels */
Plot.text(routeComparison, Plot.selectFirst({
x: "alongDistNauticalMiles",
y: 0,
text: d => "START",
textAnchor: "start",
dy: -10,
fontWeight: "bold",
fill: "black",
// TODO - check first/last values before anchoring text against axis
})),
Plot.text(routeComparison, Plot.selectLast({
x: "alongDistNauticalMiles",
y: 0,
text: d => "END",
textAnchor: "end",
dx: -5,
dy: -10,
fontWeight: "bold",
fill: "black",
// TODO - check first/last values before anchoring text against axis
})),
/* layers brushed from primary hud plot */
hudPrimaryPlot ?
Plot.dot(routeComparison.filter(d => d.index == hudPrimaryPlot.index), {
x: "alongDistNauticalMiles",
y: secondaryPlotCol,
// stroke: "type",
fill: "#1a65b0",
r: 4
}) :
null
],
x: {
label: null,
tickFormat: d => d3.format(".0f")(d) + "nm"
},
y: {
label: null,
tickFormat: secondaryPlotFormat
},
color: {
legend: false
},
marginLeft: 100,
insetRight: 20,
height: 300,
width: width,
title: secondaryPlotTitle,
subtitle: secondaryPlotSubtitle,
style: {
background: "transparent",
fontSize: 16,
fontFamily: "Inter"
}
})