Add project previews and race replay
This commit is contained in:
@@ -1 +1 @@
|
|||||||
Manyangles portfolio card uses the existing study-abroad group dinner photo at /trips/engr290/insta290.jpg, also shown on the travel page. This is illustrative portfolio imagery, not a claim that the trip used Manyangles.
|
The Manyangles portfolio card uses an illustrative sample gallery built with CSS. It contains no event-specific photos or personal details.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 311 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 238 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 331 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 239 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 316 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,93 @@
|
|||||||
|
Copyright (c) 2009-2011 by Accademia di Belle Arti di Urbino and students of MA course of Visual design. Some rights reserved.
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,218 @@
|
|||||||
|
"""Build the compact 2024 São Paulo replay snapshot from public race data.
|
||||||
|
|
||||||
|
Run from the repository root: python3 scripts/generate-race-replay.py
|
||||||
|
The website reads the checked-in snapshot and makes no live API requests.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import math
|
||||||
|
import time
|
||||||
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
|
from pathlib import Path
|
||||||
|
from urllib.parse import urlencode
|
||||||
|
from urllib.request import Request, urlopen
|
||||||
|
|
||||||
|
JOLPICA = "https://api.jolpi.ca/ergast/f1/2024/21"
|
||||||
|
OPENF1 = "https://api.openf1.org/v1"
|
||||||
|
OUTPUT = Path(__file__).resolve().parents[1] / "src/lib/race-replay-data.json"
|
||||||
|
|
||||||
|
|
||||||
|
def fetch(url: str):
|
||||||
|
for attempt in range(5):
|
||||||
|
try:
|
||||||
|
with urlopen(Request(url, headers={"User-Agent": "SeanOConnorRaceReplay/1.0"}), timeout=30) as response:
|
||||||
|
return json.load(response)
|
||||||
|
except Exception:
|
||||||
|
if attempt == 4:
|
||||||
|
raise
|
||||||
|
time.sleep(2 ** attempt)
|
||||||
|
|
||||||
|
|
||||||
|
def seconds(value: str) -> float:
|
||||||
|
total = 0.0
|
||||||
|
for part in value.split(":"):
|
||||||
|
total = total * 60 + float(part)
|
||||||
|
return round(total, 3)
|
||||||
|
|
||||||
|
|
||||||
|
def jolpica(endpoint: str):
|
||||||
|
payload = fetch(f"{JOLPICA}/{endpoint}/?limit=100")
|
||||||
|
return payload["MRData"]
|
||||||
|
|
||||||
|
|
||||||
|
first_page = jolpica("laps")
|
||||||
|
total = int(first_page["total"])
|
||||||
|
offsets = range(100, total, 100)
|
||||||
|
with ThreadPoolExecutor(max_workers=4) as pool:
|
||||||
|
more_pages = list(pool.map(lambda offset: fetch(f"{JOLPICA}/laps/?limit=100&offset={offset}")["MRData"], offsets))
|
||||||
|
|
||||||
|
all_laps = {}
|
||||||
|
for page in [first_page, *more_pages]:
|
||||||
|
for lap in page["RaceTable"]["Races"][0]["Laps"]:
|
||||||
|
all_laps.setdefault(int(lap["number"]), []).extend(lap["Timings"])
|
||||||
|
|
||||||
|
results = jolpica("results")["RaceTable"]["Races"][0]
|
||||||
|
pits = jolpica("pitstops")["RaceTable"]["Races"][0]["PitStops"]
|
||||||
|
openf1_drivers = fetch(f"{OPENF1}/drivers?session_key=9636")
|
||||||
|
driver_colors = {int(driver["driver_number"]): f"#{driver['team_colour']}" for driver in openf1_drivers}
|
||||||
|
|
||||||
|
drivers = []
|
||||||
|
for result in results["Results"]:
|
||||||
|
driver = result["Driver"]
|
||||||
|
number = int(result["number"])
|
||||||
|
drivers.append({
|
||||||
|
"id": driver["driverId"],
|
||||||
|
"number": number,
|
||||||
|
"code": driver.get("code", driver["familyName"][:3].upper()),
|
||||||
|
"name": f"{driver['givenName']} {driver['familyName']}",
|
||||||
|
"team": result["Constructor"]["name"],
|
||||||
|
"color": driver_colors.get(number, "#ffffff"),
|
||||||
|
"grid": int(result["grid"]),
|
||||||
|
"finish": int(result["position"]),
|
||||||
|
"status": result["status"],
|
||||||
|
})
|
||||||
|
|
||||||
|
laps = []
|
||||||
|
for number, timings in sorted(all_laps.items()):
|
||||||
|
laps.append({
|
||||||
|
"number": number,
|
||||||
|
"timings": [
|
||||||
|
{"id": timing["driverId"], "position": int(timing["position"]), "seconds": seconds(timing["time"])}
|
||||||
|
for timing in timings
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
pit_stops = [
|
||||||
|
{"id": pit["driverId"], "lap": int(pit["lap"]), "seconds": seconds(pit["duration"])}
|
||||||
|
for pit in pits
|
||||||
|
]
|
||||||
|
|
||||||
|
# A complete lap from Verstappen's on-car coordinates, rather than an
|
||||||
|
# unrelated circuit image or generated illustration.
|
||||||
|
lap_eight = next(item for item in fetch(f"{OPENF1}/laps?session_key=9636&driver_number=1") if item["lap_number"] == 8)
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
start = datetime.fromisoformat(lap_eight["date_start"])
|
||||||
|
end = start + timedelta(seconds=lap_eight["lap_duration"])
|
||||||
|
params = urlencode({
|
||||||
|
"session_key": 9636,
|
||||||
|
"driver_number": 1,
|
||||||
|
"date>": start.isoformat(timespec="milliseconds"),
|
||||||
|
"date<": end.isoformat(timespec="milliseconds"),
|
||||||
|
})
|
||||||
|
location = fetch(f"{OPENF1}/location?{params}")
|
||||||
|
points = [(row["x"], row["y"]) for row in location if row["x"] is not None and row["y"] is not None]
|
||||||
|
if len(points) < 100:
|
||||||
|
raise RuntimeError("Not enough location samples for the circuit trace")
|
||||||
|
if math.dist(points[0], points[-1]) > 400:
|
||||||
|
raise RuntimeError("The sampled track lap does not close")
|
||||||
|
points.append(points[0])
|
||||||
|
# Mirror the telemetry Y axis to match the FIA's 2024 circuit map: the
|
||||||
|
# start/finish straight is on the left and the back straight is on the right.
|
||||||
|
min_x, max_x = min(x for x, _ in points), max(x for x, _ in points)
|
||||||
|
min_y, max_y = min(y for _, y in points), max(y for _, y in points)
|
||||||
|
scale = min(650 / (max_x - min_x), 630 / (max_y - min_y))
|
||||||
|
offset_x = (1000 - (max_x - min_x) * scale) / 2
|
||||||
|
offset_y = (740 - (max_y - min_y) * scale) / 2
|
||||||
|
|
||||||
|
|
||||||
|
def map_point(x: float, y: float) -> list[float]:
|
||||||
|
return [round(offset_x + (x - min_x) * scale, 1), round(offset_y + (max_y - y) * scale, 1)]
|
||||||
|
|
||||||
|
|
||||||
|
track = [map_point(x, y) for x, y in points]
|
||||||
|
circuit = fetch("https://api.multiviewer.app/api/v1/circuits/14/2024")
|
||||||
|
corners = []
|
||||||
|
for corner in circuit["corners"]:
|
||||||
|
x, y = map_point(corner["trackPosition"]["x"], corner["trackPosition"]["y"])
|
||||||
|
corners.append({"number": corner["number"], "x": x, "y": y})
|
||||||
|
|
||||||
|
|
||||||
|
def nearest_track_distance(x: float, y: float) -> float:
|
||||||
|
"""Project a mapped circuit marker onto the same polyline as the cars."""
|
||||||
|
best_distance = float("inf")
|
||||||
|
best_progress = 0.0
|
||||||
|
progress = 0.0
|
||||||
|
for (ax, ay), (bx, by) in zip(track, track[1:]):
|
||||||
|
length_sq = (bx - ax) ** 2 + (by - ay) ** 2
|
||||||
|
fraction = max(0.0, min(1.0, ((x - ax) * (bx - ax) + (y - ay) * (by - ay)) / length_sq)) if length_sq else 0.0
|
||||||
|
px, py = ax + fraction * (bx - ax), ay + fraction * (by - ay)
|
||||||
|
distance = math.dist((x, y), (px, py))
|
||||||
|
if distance < best_distance:
|
||||||
|
best_distance = distance
|
||||||
|
best_progress = progress + math.sqrt(length_sq) * fraction
|
||||||
|
progress += math.sqrt(length_sq)
|
||||||
|
return best_progress
|
||||||
|
|
||||||
|
|
||||||
|
track_length = sum(math.dist(a, b) for a, b in zip(track, track[1:]))
|
||||||
|
corner_distance = {corner["number"]: nearest_track_distance(corner["x"], corner["y"]) for corner in corners}
|
||||||
|
|
||||||
|
|
||||||
|
def point_at(distance: float) -> list[float]:
|
||||||
|
remaining = distance % track_length
|
||||||
|
for (ax, ay), (bx, by) in zip(track, track[1:]):
|
||||||
|
segment = math.dist((ax, ay), (bx, by))
|
||||||
|
if remaining <= segment:
|
||||||
|
fraction = remaining / segment if segment else 0
|
||||||
|
return [round(ax + (bx - ax) * fraction, 1), round(ay + (by - ay) * fraction, 1)]
|
||||||
|
remaining -= segment
|
||||||
|
return track[0]
|
||||||
|
|
||||||
|
|
||||||
|
def zone_path(start: float, end: float) -> list[list[float]]:
|
||||||
|
if end < start:
|
||||||
|
end += track_length
|
||||||
|
# Follow the same polyline as the animated dots, including the lap seam.
|
||||||
|
samples = max(2, math.ceil((end - start) / 3))
|
||||||
|
return [point_at(start + (end - start) * index / samples) for index in range(samples + 1)]
|
||||||
|
|
||||||
|
|
||||||
|
# FIA 2024 São Paulo Circuit Map: activation 1 is 30m after T3;
|
||||||
|
# activation 2 is 160m before T15. Zones end at the next braking turn.
|
||||||
|
metres_to_track = track_length / 4309
|
||||||
|
drs_zones = [
|
||||||
|
{"number": 1, "track": zone_path(corner_distance[3] + 30 * metres_to_track, corner_distance[4] - 30 * metres_to_track)},
|
||||||
|
{"number": 2, "track": zone_path(corner_distance[15] - 160 * metres_to_track, corner_distance[1] - 30 * metres_to_track)},
|
||||||
|
]
|
||||||
|
|
||||||
|
control = fetch(f"{OPENF1}/race_control?session_key=9636")
|
||||||
|
event_specs = [
|
||||||
|
(1, "start", "A race before the race", "An aborted start delays lights out on a wet Interlagos grid.", "ABORTED START"),
|
||||||
|
(28, "vsc", "Virtual safety car", "A VSC changes the pit-stop calculation as the rain intensifies.", "VIRTUAL SAFETY CAR DEPLOYED"),
|
||||||
|
(30, "safety", "Safety car deployed", "The field bunches up in difficult conditions.", "SAFETY CAR DEPLOYED"),
|
||||||
|
(32, "red", "Red flag", "The race stops, and the order is reset for a rolling restart.", "RED FLAG"),
|
||||||
|
(33, "restart", "Rolling restart", "The field gets going again behind Ocon.", "ROLLING START"),
|
||||||
|
(39, "safety", "Safety car returns", "Another interruption bunches the field.", "SAFETY CAR DEPLOYED"),
|
||||||
|
(42, "restart", "Safety car in", "The race returns to green-flag running.", "SAFETY CAR IN THIS LAP"),
|
||||||
|
(69, "finish", "Chequered flag", "Verstappen wins from P17; Alpine finishes second and third.", "CHEQUERED FLAG"),
|
||||||
|
]
|
||||||
|
events = []
|
||||||
|
for lap, kind, title, description, message in event_specs:
|
||||||
|
if not any(item.get("lap_number") == lap and message in item["message"] for item in control):
|
||||||
|
raise RuntimeError(f"Race-control event not found: lap {lap}, {message}")
|
||||||
|
events.append({"lap": lap, "kind": kind, "title": title, "description": description})
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"race": {"title": "São Paulo Grand Prix", "year": 2024, "date": "3 November 2024", "circuit": "Autódromo José Carlos Pace", "location": "Interlagos · Brazil", "laps": 69},
|
||||||
|
"drivers": drivers,
|
||||||
|
"laps": laps,
|
||||||
|
"pitStops": pit_stops,
|
||||||
|
"events": events,
|
||||||
|
"track": track,
|
||||||
|
"corners": corners,
|
||||||
|
"drsZones": drs_zones,
|
||||||
|
"sources": {
|
||||||
|
"laps": f"{JOLPICA}/laps/",
|
||||||
|
"results": f"{JOLPICA}/results/",
|
||||||
|
"pitStops": f"{JOLPICA}/pitstops/",
|
||||||
|
"raceControl": f"{OPENF1}/race_control?session_key=9636",
|
||||||
|
"track": f"{OPENF1}/location?session_key=9636&driver_number=1",
|
||||||
|
"corners": "https://api.multiviewer.app/api/v1/circuits/14/2024",
|
||||||
|
"circuitMap": "https://www.fia.com/sites/default/files/decision-document/2024%20S%C3%A3o%20Paulo%20Grand%20Prix%20-%20Event%20Notes%20-%20Circuit%20Map,%20Pit%20Lane%20Drawing%20and%20Red%20Zones.pdf",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
OUTPUT.write_text(json.dumps(data, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||||
|
print(f"Wrote {OUTPUT}: {len(drivers)} drivers, {len(laps)} laps, {len(track)} track points")
|
||||||
+10
-14
@@ -3,6 +3,8 @@ import Image from "next/image";
|
|||||||
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
||||||
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
||||||
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
||||||
|
import { PuterPreview } from "~/components/PuterPreview";
|
||||||
|
import { ProjectBrand } from "~/components/ProjectBrand";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { ArrowUpRight } from "lucide-react";
|
import { ArrowUpRight } from "lucide-react";
|
||||||
|
|
||||||
@@ -32,7 +34,7 @@ const work = [
|
|||||||
{
|
{
|
||||||
name: "Puter",
|
name: "Puter",
|
||||||
category: "Native macOS app",
|
category: "Native macOS app",
|
||||||
image: "/images/puter.jpg",
|
image: "/portfolio/puter/01-processes-light.png",
|
||||||
alt: "Puter’s macOS performance dashboard",
|
alt: "Puter’s macOS performance dashboard",
|
||||||
tone: "puter",
|
tone: "puter",
|
||||||
href: "https://git.soconnor.dev/soconnor/Puter",
|
href: "https://git.soconnor.dev/soconnor/Puter",
|
||||||
@@ -44,7 +46,7 @@ const work = [
|
|||||||
name: "Manyangles",
|
name: "Manyangles",
|
||||||
category: "Event photo sharing",
|
category: "Event photo sharing",
|
||||||
image: "/portfolio/manyangles.svg",
|
image: "/portfolio/manyangles.svg",
|
||||||
alt: "Manyangles event photo sharing",
|
alt: "Manyangles shared albums",
|
||||||
tone: "manyangles",
|
tone: "manyangles",
|
||||||
href: "https://ma.hadlock.tech",
|
href: "https://ma.hadlock.tech",
|
||||||
description:
|
description:
|
||||||
@@ -122,6 +124,8 @@ export default function HomePage() {
|
|||||||
>
|
>
|
||||||
{project.name === "beenvoice" ? (
|
{project.name === "beenvoice" ? (
|
||||||
<BeenvoicePreview priority />
|
<BeenvoicePreview priority />
|
||||||
|
) : project.name === "Puter" ? (
|
||||||
|
<PuterPreview />
|
||||||
) : project.name === "Manyangles" ? (
|
) : project.name === "Manyangles" ? (
|
||||||
<ManyanglesPreview />
|
<ManyanglesPreview />
|
||||||
) : (
|
) : (
|
||||||
@@ -133,21 +137,11 @@ export default function HomePage() {
|
|||||||
sizes="(max-width: 700px) 100vw, 50vw"
|
sizes="(max-width: 700px) 100vw, 50vw"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{(project.name === "Racetix" || project.name === "beenvoice") && (
|
|
||||||
<div className={`studio-product-logo ${project.name === "beenvoice" ? "beenvoice-logo" : ""}`}>
|
|
||||||
<Image
|
|
||||||
src={project.name === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
|
|
||||||
alt=""
|
|
||||||
width={project.name === "Racetix" ? 1614 : 2970}
|
|
||||||
height={project.name === "Racetix" ? 304 : 436}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</a>
|
</a>
|
||||||
<div className="studio-project-title">
|
<div className="studio-project-title">
|
||||||
<h3>
|
<h3>
|
||||||
<a href={project.href}>
|
<a href={project.href}>
|
||||||
{project.name} <ArrowUpRight size={20} />
|
<ProjectBrand name={project.name} /> <ArrowUpRight size={20} />
|
||||||
</a>
|
</a>
|
||||||
</h3>
|
</h3>
|
||||||
<span>{project.category}</span>
|
<span>{project.category}</span>
|
||||||
@@ -202,7 +196,9 @@ export default function HomePage() {
|
|||||||
<JusticeParagraph text="At Bucknell, I studied Computer Science and Engineering, built tools for robotics research, and helped other students learn to build things. Now I’m at BU, aiming to finish my master’s in a year while continuing my own projects." />
|
<JusticeParagraph text="At Bucknell, I studied Computer Science and Engineering, built tools for robotics research, and helped other students learn to build things. Now I’m at BU, aiming to finish my master’s in a year while continuing my own projects." />
|
||||||
<p>
|
<p>
|
||||||
Outside of software, I’m into specialty coffee, Formula One, and
|
Outside of software, I’m into specialty coffee, Formula One, and
|
||||||
travel. I cofounded Bucknell’s Coffee Society—and, naturally, made a{" "}
|
travel. I built a{" "}
|
||||||
|
<a className="coffee-link" href="/race-replay">lap-by-lap race replay</a>
|
||||||
|
{" "}for the 2024 São Paulo Grand Prix. I also cofounded Bucknell’s Coffee Society—and, naturally, made a{" "}
|
||||||
<a className="coffee-link" href="/coffee">
|
<a className="coffee-link" href="/coffee">
|
||||||
coffee map for Lewisburg and Boston
|
coffee map for Lewisburg and Boston
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
import type { Metadata } from "next";
|
||||||
|
import { RaceReplay } from "~/components/RaceReplay";
|
||||||
|
import "./race-replay.css";
|
||||||
|
|
||||||
|
export const metadata: Metadata = {
|
||||||
|
title: "Race Replay — São Paulo 2024",
|
||||||
|
description: "Explore the 2024 São Paulo Grand Prix lap by lap on an interactive Interlagos circuit map.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RaceReplayPage() {
|
||||||
|
return <RaceReplay />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
@font-face {
|
||||||
|
font-family: "Race Titillium";
|
||||||
|
src: url("/race/fonts/TitilliumWeb-Regular.ttf") format("truetype");
|
||||||
|
font-weight: 400;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Race Titillium";
|
||||||
|
src: url("/race/fonts/TitilliumWeb-Bold.ttf") format("truetype");
|
||||||
|
font-weight: 700;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
@font-face {
|
||||||
|
font-family: "Race Titillium";
|
||||||
|
src: url("/race/fonts/TitilliumWeb-Black.ttf") format("truetype");
|
||||||
|
font-weight: 900;
|
||||||
|
font-display: swap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.race-site-main { width: 100%; max-width: none; padding: 0; }
|
||||||
|
.race-replay {
|
||||||
|
--race-bg: #f2f2f4;
|
||||||
|
--race-surface: #fff;
|
||||||
|
--race-ink: #15151e;
|
||||||
|
--race-muted: #62626b;
|
||||||
|
--race-line: #d8d8dc;
|
||||||
|
--race-red: #e10600;
|
||||||
|
--race-red-soft: #ffe9e8;
|
||||||
|
min-height: 100vh;
|
||||||
|
background: var(--race-bg);
|
||||||
|
color: var(--race-ink);
|
||||||
|
font-family: "Race Titillium", "Helvetica Neue", Arial, sans-serif;
|
||||||
|
}
|
||||||
|
.race-replay * { box-sizing: border-box; }
|
||||||
|
.race-replay button { font: inherit; }
|
||||||
|
.race-replay button:focus-visible, .race-replay a:focus-visible, .race-replay input:focus-visible { outline: 2px solid var(--race-red); outline-offset: 3px; }
|
||||||
|
.replay-wrap { width: min(1500px, 100%); margin: auto; padding: 40px clamp(18px, 3.4vw, 54px) 34px; }
|
||||||
|
.replay-intro { display: flex; justify-content: space-between; gap: 48px; align-items: end; min-height: 232px; margin-bottom: 34px; }
|
||||||
|
.replay-intro-copy { min-width: 0; }
|
||||||
|
.replay-eyebrow, .replay-intro-meta, .replay-panel-heading p, .replay-panel-heading .replay-track-tag, .replay-stage-top, .replay-map-legend, .replay-playback-top span, .replay-sidebar-head, .replay-driver-focus > p, .replay-focus-stats span, .replay-moments-title p, .replay-moments-title > span, .replay-moment > span:first-child, .replay-sources { font-size: 10px; font-weight: 720; letter-spacing: .15em; }
|
||||||
|
.replay-eyebrow { display: flex; align-items: center; gap: 12px; color: var(--race-red); }
|
||||||
|
.replay-live-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--race-red); box-shadow: 0 0 0 4px color-mix(in srgb, var(--race-red) 14%, transparent); }
|
||||||
|
.replay-eyebrow-divider { color: var(--race-muted); font-weight: 400; }
|
||||||
|
.replay-intro h1 { margin: 18px 0 10px; font-family: "Race Titillium", "Helvetica Neue", sans-serif; font-weight: 900; font-size: clamp(58px, 7.5vw, 118px); line-height: .94; letter-spacing: -.04em; text-transform: uppercase; }
|
||||||
|
.replay-intro h1 em { color: var(--race-red); font-style: italic; }
|
||||||
|
.replay-title-period { color: var(--race-red); }
|
||||||
|
.replay-intro-copy > p { max-width: 650px; margin: 20px 0 20px; color: var(--race-muted); font-size: clamp(13px, 1.15vw, 16px); line-height: 1.6; }
|
||||||
|
.replay-intro-meta { display: flex; align-items: center; flex-wrap: wrap; gap: 10px 23px; color: var(--race-muted); }
|
||||||
|
.replay-intro-meta span { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.replay-headline-stat { flex: 0 0 254px; padding: 16px 0 7px 24px; border-left: 2px solid var(--race-red); }
|
||||||
|
.replay-headline-stat > span { color: var(--race-muted); font-size: 10px; font-weight: 760; letter-spacing: .18em; }
|
||||||
|
.replay-headline-stat strong { display: block; margin-top: 7px; font-size: clamp(44px, 4.2vw, 70px); font-weight: 850; line-height: 1; letter-spacing: -.095em; white-space: nowrap; }
|
||||||
|
.replay-headline-stat small { font-size: .45em; letter-spacing: -.03em; }
|
||||||
|
.replay-headline-stat i { color: var(--race-red); font-size: .7em; font-style: normal; }
|
||||||
|
.replay-headline-stat p { margin: 9px 0 0; color: var(--race-muted); font-size: 11px; }
|
||||||
|
.replay-dashboard { display: grid; grid-template-columns: minmax(0, 1fr) minmax(300px, 340px); gap: 16px; align-items: start; }
|
||||||
|
.replay-map-panel, .replay-sidebar { min-width: 0; border: 1px solid var(--race-line); background: var(--race-surface); box-shadow: 0 8px 35px #0a1e2a08; }
|
||||||
|
.replay-panel-heading { display: flex; align-items: center; justify-content: space-between; gap: 10px; padding: 19px 24px 17px; }
|
||||||
|
.replay-panel-heading p, .replay-moments-title p { margin: 0 0 7px; color: var(--race-red); }
|
||||||
|
.replay-panel-heading h2 { margin: 0; font-size: clamp(16px, 1.5vw, 22px); font-weight: 700; letter-spacing: -.02em; }
|
||||||
|
.replay-track-tag { padding: 8px 9px; border: 1px solid var(--race-line); color: var(--race-muted); white-space: nowrap; }
|
||||||
|
.replay-map-stage { position: relative; height: clamp(540px, 54vw, 700px); overflow: hidden; background: radial-gradient(ellipse at 50% 48%, #252530, #15151e 64%, #0d0d14 100%); color: #f5f5f6; }
|
||||||
|
.replay-map-stage::before { content: ""; position: absolute; inset: 0; opacity: .46; background-image: linear-gradient(#75849217 1px, transparent 1px), linear-gradient(90deg, #75849217 1px, transparent 1px); background-size: 38px 38px; mask-image: linear-gradient(120deg, transparent, #000 20%, #000 80%, transparent); }
|
||||||
|
.replay-map-stage::after { content: ""; position: absolute; inset: 0; pointer-events: none; border: 1px solid #eaf3fd14; box-shadow: inset 0 0 130px #07090d60; }
|
||||||
|
.replay-stage-top { position: absolute; top: 20px; left: 24px; right: 24px; z-index: 2; display: flex; justify-content: space-between; gap: 12px; color: #8da2b5; }
|
||||||
|
.replay-circuit { position: absolute; inset: 10px 0 11px; width: 100%; height: calc(100% - 21px); overflow: visible; }
|
||||||
|
.replay-track-shadow, .replay-track-outer, .replay-drs-zone path { fill: none; stroke-linecap: round; stroke-linejoin: round; }
|
||||||
|
.replay-track-shadow { stroke: #79818a; stroke-width: 40; opacity: .25; filter: blur(12px); }
|
||||||
|
.replay-track-outer { stroke: #e0e2e6; stroke-width: 24; }
|
||||||
|
.replay-drs-zone path { stroke: #23d160; stroke-width: 24; }
|
||||||
|
.replay-drs-zone text { fill: #40e57b; font-family: "Race Titillium", Arial, sans-serif; font-size: 17px; font-weight: 900; letter-spacing: .12em; dominant-baseline: middle; paint-order: stroke; stroke: #15151e; stroke-width: 5px; }
|
||||||
|
.replay-car text { fill: #fff; font-family: "Race Titillium", Arial, sans-serif; font-size: 15px; font-weight: 900; letter-spacing: .04em; paint-order: stroke; stroke: #091017; stroke-width: 4px; }
|
||||||
|
.replay-corner circle { fill: #dfe8f0; stroke: #1d2833; stroke-width: 2; }
|
||||||
|
.replay-corner text { fill: #b3b7bf; font-family: "Race Titillium", Arial, sans-serif; font-size: 14px; font-weight: 700; letter-spacing: .06em; paint-order: stroke; stroke: #10151d; stroke-width: 4px; }
|
||||||
|
.replay-start-marker rect { fill: #fafafa; stroke: #151b20; stroke-width: 2; }
|
||||||
|
.replay-start-marker text { fill: #c1c3c8; font-family: "Race Titillium", Arial, sans-serif; font-size: 14px; font-weight: 700; letter-spacing: .07em; }
|
||||||
|
.replay-map-legend { position: absolute; bottom: 18px; left: 24px; right: 24px; z-index: 3; display: flex; align-items: center; gap: 18px; color: #94a5b4; }
|
||||||
|
.replay-map-legend span { display: inline-flex; align-items: center; gap: 7px; }
|
||||||
|
.replay-map-legend i { display: inline-block; width: 9px; height: 9px; border: 2px solid #c0cede; border-radius: 50%; }
|
||||||
|
.replay-map-legend .replay-drs-legend i { width: 14px; height: 4px; border: 0; border-radius: 0; background: #23d160; }
|
||||||
|
.replay-map-legend button { margin-left: auto; border: 1px solid #63718177; background: #121b24b8; padding: 7px 10px; color: #d0dae2; font-size: 9px; font-weight: 760; letter-spacing: .1em; cursor: pointer; }
|
||||||
|
.replay-map-legend button:hover { background: #263342; }
|
||||||
|
.replay-stage-event { position: absolute; top: 60px; right: 24px; z-index: 3; display: flex; align-items: center; gap: 9px; max-width: 220px; padding: 10px 12px; background: #121923d9; border-left: 3px solid #eb3545; box-shadow: 0 8px 20px #0005; }
|
||||||
|
.replay-stage-event span { font-size: 9px; letter-spacing: .12em; color: #a5b1bd; white-space: nowrap; }
|
||||||
|
.replay-stage-event strong { font-size: 11px; font-weight: 720; }
|
||||||
|
.replay-stage-event--vsc, .replay-stage-event--safety { border-left-color: #f5c445; }
|
||||||
|
.replay-stage-event--restart { border-left-color: #70ce9a; }
|
||||||
|
.replay-playback { padding: 20px 24px 17px; }
|
||||||
|
.replay-playback-top { display: flex; justify-content: space-between; gap: 16px; align-items: end; margin-bottom: 13px; }
|
||||||
|
.replay-playback-top span { display: block; margin-bottom: 4px; color: var(--race-muted); }
|
||||||
|
.replay-playback-top strong { font-size: 28px; line-height: 1; letter-spacing: -.06em; font-variant-numeric: tabular-nums; }
|
||||||
|
.replay-lap-readout { text-align: right; }
|
||||||
|
.replay-lap-readout strong { font-size: 32px; }
|
||||||
|
.replay-lap-readout small { color: var(--race-muted); font-size: 16px; }
|
||||||
|
.replay-transport { display: flex; align-items: center; gap: 5px; }
|
||||||
|
.replay-transport button { display: grid; place-items: center; height: 38px; flex: none; cursor: pointer; }
|
||||||
|
.replay-play-button { width: 43px; border: none; background: var(--race-red); color: #fff; }
|
||||||
|
.replay-play-button:hover { filter: brightness(1.15); }
|
||||||
|
.replay-step-button, .replay-reset-button { width: 34px; border: none; background: transparent; color: var(--race-muted); }
|
||||||
|
.replay-step-button:hover, .replay-reset-button:hover { color: var(--race-ink); background: var(--race-bg); }
|
||||||
|
.replay-scrubber { position: relative; flex: 1; min-width: 60px; height: 35px; display: flex; align-items: center; }
|
||||||
|
.replay-scrubber input { width: 100%; height: 24px; margin: 0; appearance: none; background: transparent; cursor: pointer; position: relative; z-index: 2; }
|
||||||
|
.replay-scrubber input::-webkit-slider-runnable-track { height: 4px; border-radius: 0; background: linear-gradient(90deg, var(--race-red) 0 var(--replay-progress), var(--race-line) var(--replay-progress) 100%); }
|
||||||
|
.replay-scrubber input::-moz-range-track { height: 4px; background: var(--race-line); }
|
||||||
|
.replay-scrubber input::-moz-range-progress { height: 4px; background: var(--race-red); }
|
||||||
|
.replay-scrubber input::-webkit-slider-thumb { width: 14px; height: 14px; margin-top: -5px; appearance: none; border: 3px solid var(--race-surface); border-radius: 50%; background: var(--race-red); box-shadow: 0 0 0 1px var(--race-red); }
|
||||||
|
.replay-scrubber input::-moz-range-thumb { width: 10px; height: 10px; border: 3px solid var(--race-surface); border-radius: 50%; background: var(--race-red); box-shadow: 0 0 0 1px var(--race-red); }
|
||||||
|
.replay-timeline-ticks { position: absolute; top: 26px; inset-inline: 7px; height: 6px; pointer-events: none; }
|
||||||
|
.replay-timeline-ticks span { position: absolute; top: 0; width: 3px; height: 6px; background: var(--race-red); transform: translateX(-50%); }
|
||||||
|
.replay-timeline-ticks .is-safety, .replay-timeline-ticks .is-vsc { background: #d9ab25; }
|
||||||
|
.replay-speed { display: flex; align-items: center; margin-left: 3px; border: 1px solid var(--race-line); }
|
||||||
|
.replay-speed button { width: 30px; height: 30px; border: none; background: transparent; color: var(--race-muted); font-size: 10px; font-weight: 760; }
|
||||||
|
.replay-speed button[aria-pressed="true"] { color: #fff; background: var(--race-ink); }
|
||||||
|
.replay-playback > p { margin: 12px 0 0; color: var(--race-muted); font-size: 10px; line-height: 1.5; }
|
||||||
|
.replay-sidebar-head { display: flex; justify-content: space-between; padding: 19px 18px 14px; border-bottom: 1px solid var(--race-line); color: var(--race-muted); }
|
||||||
|
.replay-sidebar-head strong { color: var(--race-red); }
|
||||||
|
.replay-leaderboard { height: 506px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--race-line) transparent; }
|
||||||
|
.replay-driver-row { display: flex; align-items: center; width: 100%; height: 45px; padding: 0 13px; border: none; border-bottom: 1px solid var(--race-line); background: transparent; color: var(--race-ink); text-align: left; cursor: pointer; }
|
||||||
|
.replay-driver-row:hover { background: var(--race-bg); }
|
||||||
|
.replay-driver-row.is-selected { background: var(--race-red-soft); }
|
||||||
|
.replay-driver-row.is-out { opacity: .48; }
|
||||||
|
.replay-row-position { width: 28px; color: var(--race-muted); font-size: 13px; font-weight: 850; font-variant-numeric: tabular-nums; }
|
||||||
|
.replay-row-color { width: 4px; height: 24px; margin-right: 10px; }
|
||||||
|
.replay-row-identity { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 0; }
|
||||||
|
.replay-row-identity strong { font-size: 13px; font-weight: 830; letter-spacing: .015em; }
|
||||||
|
.replay-row-identity small { overflow: hidden; color: var(--race-muted); font-size: 9px; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
|
.replay-row-change { color: var(--race-muted); font-size: 11px; font-weight: 780; font-variant-numeric: tabular-nums; }
|
||||||
|
.replay-row-change.is-up { color: #18895f; }
|
||||||
|
.replay-row-change.is-down { color: var(--race-red); }
|
||||||
|
.replay-driver-focus { padding: 18px; border-top: 2px solid var(--race-ink); }
|
||||||
|
.replay-driver-focus > p { display: flex; justify-content: space-between; margin: 0 0 12px; color: var(--race-muted); }
|
||||||
|
.replay-focus-head { display: flex; align-items: start; justify-content: space-between; gap: 8px; }
|
||||||
|
.replay-focus-head h3 { margin: 0; font-size: 20px; font-weight: 700; letter-spacing: -.02em; }
|
||||||
|
.replay-focus-head span:not(.replay-focus-position) { color: var(--race-muted); font-size: 11px; }
|
||||||
|
.replay-focus-position { font-size: 35px; font-weight: 900; letter-spacing: -.07em; line-height: 1; }
|
||||||
|
.replay-focus-stats { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 7px; margin-top: 17px; }
|
||||||
|
.replay-focus-stats div { padding: 8px 0; border-top: 1px solid var(--race-line); }
|
||||||
|
.replay-focus-stats span { display: block; margin-bottom: 4px; color: var(--race-muted); font-size: 8px; }
|
||||||
|
.replay-focus-stats strong { font-size: 14px; font-variant-numeric: tabular-nums; }
|
||||||
|
.replay-chart { position: relative; margin-top: 9px; padding-left: 23px; }
|
||||||
|
.replay-chart svg { display: block; width: 100%; height: 74px; overflow: visible; }
|
||||||
|
.replay-chart line { stroke: var(--race-line); stroke-width: 1; stroke-dasharray: 3 4; }
|
||||||
|
.replay-chart-axis { position: absolute; top: 0; bottom: 16px; left: 0; display: flex; flex-direction: column; justify-content: space-between; color: var(--race-muted); font-size: 9px; }
|
||||||
|
.replay-chart-bottom { display: flex; justify-content: space-between; margin-top: 4px; color: var(--race-muted); font-size: 8px; font-weight: 750; letter-spacing: .12em; }
|
||||||
|
.replay-no-laps { margin: 16px 0 0; color: var(--race-muted); font-size: 11px; }
|
||||||
|
.replay-moments { margin-top: 34px; }
|
||||||
|
.replay-moments-title { display: flex; align-items: end; justify-content: space-between; gap: 15px; margin-bottom: 15px; }
|
||||||
|
.replay-moments-title h2 { margin: 0; font-size: clamp(26px, 2.6vw, 38px); font-weight: 700; letter-spacing: -.02em; }
|
||||||
|
.replay-moments-title > span { color: var(--race-muted); white-space: nowrap; }
|
||||||
|
.replay-moments-track { display: grid; grid-auto-columns: minmax(215px, 1fr); grid-auto-flow: column; gap: 10px; overflow-x: auto; padding-bottom: 10px; scrollbar-width: thin; }
|
||||||
|
.replay-moment { position: relative; min-height: 148px; padding: 15px 35px 15px 16px; border: 1px solid var(--race-line); border-top: 3px solid var(--race-line); background: var(--race-surface); color: var(--race-ink); text-align: left; cursor: pointer; }
|
||||||
|
.replay-moment:hover, .replay-moment.is-active { border-top-color: var(--race-red); background: var(--race-red-soft); }
|
||||||
|
.replay-moment > span:first-child { display: flex; align-items: center; gap: 8px; color: var(--race-red); }
|
||||||
|
.replay-moment i { display: inline-block; width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||||
|
.replay-moment i.is-safety, .replay-moment i.is-vsc { color: #dbac1b; }
|
||||||
|
.replay-moment strong { display: block; margin-top: 12px; font-size: 15px; letter-spacing: -.035em; }
|
||||||
|
.replay-moment p { margin: 7px 0 0; color: var(--race-muted); font-size: 10px; line-height: 1.45; }
|
||||||
|
.replay-moment-arrow { position: absolute; right: 13px; bottom: 13px; color: var(--race-red); font-size: 15px; }
|
||||||
|
.replay-sources { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 14px; margin-top: 24px; padding-top: 18px; border-top: 1px solid var(--race-line); color: var(--race-muted); font-size: 9px; }
|
||||||
|
.replay-sources div { display: flex; align-items: center; gap: 12px; }
|
||||||
|
.replay-sources a { display: inline-flex; align-items: center; gap: 2px; color: inherit; text-decoration: none; }
|
||||||
|
.replay-sources a:hover { color: var(--race-red); }
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.race-replay { --race-bg: #0d0d14; --race-surface: #15151e; --race-ink: #f2f2f4; --race-muted: #a6a6ae; --race-line: #34343e; --race-red: #ff322b; --race-red-soft: #371c20; }
|
||||||
|
.replay-map-panel, .replay-sidebar { box-shadow: none; }
|
||||||
|
.replay-row-change.is-up { color: #78d7a8; }
|
||||||
|
.replay-speed button[aria-pressed="true"] { background: #e5edf2; color: #111820; }
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1050px) {
|
||||||
|
.replay-dashboard { grid-template-columns: minmax(0, 1fr) 300px; }
|
||||||
|
.replay-headline-stat { flex-basis: 210px; }
|
||||||
|
.replay-track-tag { display: none; }
|
||||||
|
.replay-map-stage { height: 640px; }
|
||||||
|
}
|
||||||
|
@media (max-width: 800px) {
|
||||||
|
.replay-wrap { padding-top: 28px; }
|
||||||
|
.replay-intro { align-items: start; gap: 14px; margin-bottom: 24px; }
|
||||||
|
.replay-headline-stat { flex-basis: 160px; padding-left: 13px; }
|
||||||
|
.replay-headline-stat strong { font-size: 40px; }
|
||||||
|
.replay-headline-stat p { font-size: 9px; }
|
||||||
|
.replay-dashboard { grid-template-columns: 1fr; }
|
||||||
|
.replay-map-stage { height: min(85vw, 650px); min-height: 500px; }
|
||||||
|
.replay-sidebar { display: grid; grid-template-columns: minmax(0, 1fr) minmax(260px, .85fr); }
|
||||||
|
.replay-sidebar-head { grid-column: 1 / -1; }
|
||||||
|
.replay-leaderboard { height: 510px; }
|
||||||
|
.replay-driver-focus { border-top: 0; border-left: 1px solid var(--race-line); }
|
||||||
|
}
|
||||||
|
@media (max-width: 580px) {
|
||||||
|
.replay-wrap { padding: 22px 12px 30px; }
|
||||||
|
.replay-intro { display: block; padding-inline: 5px; }
|
||||||
|
.replay-intro h1 { font-size: clamp(51px, 12vw, 70px); }
|
||||||
|
.replay-intro-copy > p { margin: 15px 0; font-size: 12px; }
|
||||||
|
.replay-intro-meta { gap: 8px 15px; font-size: 9px; }
|
||||||
|
.replay-headline-stat { display: none; }
|
||||||
|
.replay-panel-heading { padding: 16px; }
|
||||||
|
.replay-map-stage { height: 500px; min-height: 0; }
|
||||||
|
.replay-circuit { inset: 22px 0 5px; height: calc(100% - 27px); }
|
||||||
|
.replay-stage-top { top: 14px; left: 13px; right: 13px; font-size: 8px; }
|
||||||
|
.replay-stage-top span:last-child { display: none; }
|
||||||
|
.replay-stage-event { top: 38px; right: 13px; padding: 7px; max-width: 130px; flex-direction: column; align-items: start; gap: 2px; }
|
||||||
|
.replay-stage-event strong { font-size: 9px; }
|
||||||
|
.replay-map-legend { left: 13px; right: 13px; bottom: 10px; gap: 6px; font-size: 8px; }
|
||||||
|
.replay-map-legend span:nth-child(2) { display: none; }
|
||||||
|
.replay-map-legend button { font-size: 8px; }
|
||||||
|
.replay-playback { padding: 15px 12px; }
|
||||||
|
.replay-transport { gap: 0; }
|
||||||
|
.replay-play-button { width: 38px; }
|
||||||
|
.replay-step-button { width: 27px; }
|
||||||
|
.replay-reset-button { display: none !important; }
|
||||||
|
.replay-speed { margin-left: 5px; }
|
||||||
|
.replay-speed button { width: 25px; }
|
||||||
|
.replay-sidebar { display: block; }
|
||||||
|
.replay-leaderboard { height: 368px; }
|
||||||
|
.replay-driver-focus { border-left: 0; border-top: 2px solid var(--race-ink); }
|
||||||
|
.replay-moments-title { align-items: start; }
|
||||||
|
.replay-moments-title > span { display: none; }
|
||||||
|
.replay-moments-track { grid-auto-columns: minmax(215px, 72%); }
|
||||||
|
.replay-sources { line-height: 1.7; }
|
||||||
|
}
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.replay-car { transition: none; }
|
||||||
|
}
|
||||||
@@ -33,6 +33,7 @@ export function Footer() {
|
|||||||
<Link href="/publications">Research</Link>
|
<Link href="/publications">Research</Link>
|
||||||
<Link href="/articles">In the press</Link>
|
<Link href="/articles">In the press</Link>
|
||||||
<Link href="/coffee">Coffee map</Link>
|
<Link href="/coffee">Coffee map</Link>
|
||||||
|
<Link href="/race-replay">Race Replay</Link>
|
||||||
<Link href="/travel">Trips & interests</Link>
|
<Link href="/travel">Trips & interests</Link>
|
||||||
<Link href="/october">October</Link>
|
<Link href="/october">October</Link>
|
||||||
<Link href="/cv">Résumé & CV</Link>
|
<Link href="/cv">Résumé & CV</Link>
|
||||||
|
|||||||
@@ -1,39 +1,34 @@
|
|||||||
import Image from "next/image";
|
import { ArrowUpRight, ImagePlus, Images } from "lucide-react";
|
||||||
import localFont from "next/font/local";
|
|
||||||
|
|
||||||
const geologica = localFont({
|
|
||||||
src: "../../public/portfolio/fonts/geologica.woff2",
|
|
||||||
display: "swap",
|
|
||||||
weight: "100 900",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function ManyanglesPreview() {
|
export function ManyanglesPreview() {
|
||||||
return (
|
return (
|
||||||
<div className="manyangles-preview">
|
<div
|
||||||
<Image
|
className="manyangles-preview"
|
||||||
src="/trips/engr290/insta290.jpg"
|
role="img"
|
||||||
alt="Sean’s study-abroad group gathered for dinner in Paris"
|
aria-label="Illustration of a generic Manyangles shared album with guest photo tiles"
|
||||||
fill
|
>
|
||||||
sizes="(max-width: 700px) 100vw, 50vw"
|
<div className="manyangles-demo" aria-hidden="true">
|
||||||
/>
|
<div className="manyangles-demo-bar">
|
||||||
<div className="manyangles-lockup">
|
<span className="manyangles-demo-bar-title"><Images size={15} /> Shared album</span>
|
||||||
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
<span className="manyangles-demo-share">Share album <ArrowUpRight size={13} /></span>
|
||||||
<path
|
</div>
|
||||||
d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6"
|
<div className="manyangles-demo-content">
|
||||||
stroke="currentColor"
|
<div className="manyangles-demo-intro">
|
||||||
strokeWidth="3"
|
<span>GUEST GALLERY</span>
|
||||||
strokeLinecap="round"
|
<strong>Every angle, in one place.</strong>
|
||||||
/>
|
<p>A shared home for the moments everyone captured.</p>
|
||||||
<circle cx="21" cy="11" r="2.25" fill="currentColor" />
|
</div>
|
||||||
<path
|
<div className="manyangles-demo-photos">
|
||||||
d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8"
|
<div className="manyangles-demo-photo manyangles-demo-photo--one"><span /></div>
|
||||||
stroke="currentColor"
|
<div className="manyangles-demo-photo manyangles-demo-photo--two"><span /></div>
|
||||||
strokeWidth="3"
|
<div className="manyangles-demo-photo manyangles-demo-photo--three"><span /></div>
|
||||||
strokeLinecap="round"
|
<div className="manyangles-demo-photo manyangles-demo-photo--four"><span /></div>
|
||||||
strokeLinejoin="round"
|
</div>
|
||||||
/>
|
<div className="manyangles-demo-bottom">
|
||||||
</svg>
|
<span><ImagePlus size={15} /> Photos from everyone, together</span>
|
||||||
<span className={geologica.className}>Manyangles</span>
|
<span>Original quality</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { ChevronDown, Menu, X, Home, Book, Newspaper, FolderGit2, BookOpenText, Briefcase, Plane, FileText } from "lucide-react";
|
import { ChevronDown, Menu, X, Home, Book, Newspaper, FolderGit2, BookOpenText, Briefcase, Plane, FileText, FlagTriangleRight } from "lucide-react";
|
||||||
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
|
||||||
import { Button } from "~/components/ui/button";
|
import { Button } from "~/components/ui/button";
|
||||||
import {
|
import {
|
||||||
@@ -35,6 +35,7 @@ const mobilePages = [
|
|||||||
{ href: "/publications", label: "Publications", icon: BookOpenText },
|
{ href: "/publications", label: "Publications", icon: BookOpenText },
|
||||||
{ href: "/experience", label: "Experience", icon: Briefcase },
|
{ href: "/experience", label: "Experience", icon: Briefcase },
|
||||||
{ href: "/travel", label: "Travel", icon: Plane },
|
{ href: "/travel", label: "Travel", icon: Plane },
|
||||||
|
{ href: "/race-replay", label: "Race Replay", icon: FlagTriangleRight },
|
||||||
{ href: "/cv", label: "Résumé & CV", icon: FileText },
|
{ href: "/cv", label: "Résumé & CV", icon: FileText },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
import Image from "next/image";
|
||||||
|
import localFont from "next/font/local";
|
||||||
|
|
||||||
|
const geologica = localFont({
|
||||||
|
src: "../../public/portfolio/fonts/geologica.woff2",
|
||||||
|
display: "swap",
|
||||||
|
weight: "100 900",
|
||||||
|
});
|
||||||
|
|
||||||
|
export function ProjectBrand({ name }: { name: string }) {
|
||||||
|
if (name === "Racetix" || name === "beenvoice") {
|
||||||
|
return (
|
||||||
|
<span className={`project-brand project-brand--${name.toLowerCase()}`}>
|
||||||
|
<Image
|
||||||
|
src={name === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
|
||||||
|
alt={name}
|
||||||
|
width={name === "Racetix" ? 1614 : 2970}
|
||||||
|
height={name === "Racetix" ? 304 : 436}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "Puter") {
|
||||||
|
return (
|
||||||
|
<span className="project-brand project-brand--puter">
|
||||||
|
<Image src="/portfolio/puter/icon.png" alt="" width={30} height={30} />
|
||||||
|
<span>Puter</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name === "Manyangles") {
|
||||||
|
return (
|
||||||
|
<span className={`project-brand project-brand--manyangles ${geologica.className}`}>
|
||||||
|
<svg viewBox="0 0 32 32" fill="none" aria-hidden="true">
|
||||||
|
<path d="M14 4H8a4 4 0 0 0-4 4v6M18 4h6a4 4 0 0 1 4 4v6M28 18v6a4 4 0 0 1-4 4h-6M14 28H8a4 4 0 0 1-4-4v-6" stroke="currentColor" strokeWidth="3" strokeLinecap="round" />
|
||||||
|
<circle cx="21" cy="11" r="2.25" fill="currentColor" />
|
||||||
|
<path d="m8.5 22 5-6.2a1.7 1.7 0 0 1 2.6-.1l2.6 2.9 1.7-1.8a1.7 1.7 0 0 1 2.5 0l1.6 1.8" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<span>Manyangles</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{name}</span>;
|
||||||
|
}
|
||||||
@@ -6,6 +6,8 @@ import Link from "next/link";
|
|||||||
import { ArrowUpRight, Search } from "lucide-react";
|
import { ArrowUpRight, Search } from "lucide-react";
|
||||||
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
import { BeenvoicePreview } from "~/components/BeenvoicePreview";
|
||||||
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
import { ManyanglesPreview } from "~/components/ManyanglesPreview";
|
||||||
|
import { PuterPreview } from "~/components/PuterPreview";
|
||||||
|
import { ProjectBrand } from "~/components/ProjectBrand";
|
||||||
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
import { JusticeParagraph } from "~/components/JusticeParagraph";
|
||||||
import type { Project } from "~/lib/data";
|
import type { Project } from "~/lib/data";
|
||||||
|
|
||||||
@@ -32,6 +34,8 @@ function ProjectCard({
|
|||||||
{project.image ? (
|
{project.image ? (
|
||||||
project.title === "beenvoice" ? (
|
project.title === "beenvoice" ? (
|
||||||
<BeenvoicePreview priority={index < 2} />
|
<BeenvoicePreview priority={index < 2} />
|
||||||
|
) : project.title === "Puter" ? (
|
||||||
|
<PuterPreview interactive={!compact} priority={index < 2} />
|
||||||
) : project.title === "Manyangles" ? (
|
) : project.title === "Manyangles" ? (
|
||||||
<ManyanglesPreview />
|
<ManyanglesPreview />
|
||||||
) : (
|
) : (
|
||||||
@@ -46,20 +50,10 @@ function ProjectCard({
|
|||||||
) : (
|
) : (
|
||||||
<span aria-hidden="true">{project.title}</span>
|
<span aria-hidden="true">{project.title}</span>
|
||||||
)}
|
)}
|
||||||
{(project.title === "Racetix" || project.title === "beenvoice") && (
|
|
||||||
<div className={`studio-product-logo ${project.title === "beenvoice" ? "beenvoice-logo" : ""}`}>
|
|
||||||
<Image
|
|
||||||
src={project.title === "Racetix" ? "/portfolio/racetix-logo.svg" : "/portfolio/beenvoice-logo.svg"}
|
|
||||||
alt=""
|
|
||||||
width={project.title === "Racetix" ? 1614 : 2970}
|
|
||||||
height={project.title === "Racetix" ? 304 : 436}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="archive-body">
|
<div className="archive-body">
|
||||||
<div className="archive-card-heading">
|
<div className="archive-card-heading">
|
||||||
<h2>{project.title}</h2>
|
<h2><ProjectBrand name={project.title} /></h2>
|
||||||
<span aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
<span aria-hidden="true">{String(index + 1).padStart(2, "0")}</span>
|
||||||
</div>
|
</div>
|
||||||
<p>{project.description}</p>
|
<p>{project.description}</p>
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useState } from "react";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
|
const screenshots = [
|
||||||
|
{ src: "/portfolio/puter/01-processes-light.png", label: "Processes", alt: "Puter showing grouped processes and system activity in light mode" },
|
||||||
|
{ src: "/portfolio/puter/02-cpu-light.png", label: "CPU", alt: "Puter CPU performance summary with a live utilization graph" },
|
||||||
|
{ src: "/portfolio/puter/03-cpu-cores-light.png", label: "Cores", alt: "Puter showing individual CPU core utilization graphs" },
|
||||||
|
{ src: "/portfolio/puter/04-memory-light.png", label: "Memory", alt: "Puter memory performance and pressure overview" },
|
||||||
|
{ src: "/portfolio/puter/05-processes-dark.png", label: "Dark mode", alt: "Puter grouped process view in dark mode" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function PuterPreview({ interactive = false, priority = false }: { interactive?: boolean; priority?: boolean }) {
|
||||||
|
const [active, setActive] = useState(0);
|
||||||
|
const screenshot = screenshots[active]!;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`puter-preview ${active === 4 ? "puter-preview--dark" : ""} ${interactive ? "puter-preview--interactive" : ""}`}>
|
||||||
|
<Image
|
||||||
|
className="puter-preview-screen"
|
||||||
|
src={screenshot.src}
|
||||||
|
alt={screenshot.alt}
|
||||||
|
fill
|
||||||
|
sizes="(max-width: 700px) 100vw, 50vw"
|
||||||
|
priority={priority}
|
||||||
|
/>
|
||||||
|
{interactive && (
|
||||||
|
<div className="puter-preview-controls" aria-label="Puter screenshots">
|
||||||
|
{screenshots.map((item, index) => (
|
||||||
|
<button
|
||||||
|
key={item.src}
|
||||||
|
type="button"
|
||||||
|
aria-label={`Show ${item.label} screenshot`}
|
||||||
|
aria-pressed={active === index}
|
||||||
|
onClick={() => setActive(index)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { ArrowUpRight, ChevronLeft, ChevronRight, CloudRain, FlagTriangleRight, Pause, Play, RotateCcw } from "lucide-react";
|
||||||
|
import rawData from "~/lib/race-replay-data.json";
|
||||||
|
|
||||||
|
type Driver = {
|
||||||
|
id: string;
|
||||||
|
number: number;
|
||||||
|
code: string;
|
||||||
|
name: string;
|
||||||
|
team: string;
|
||||||
|
color: string;
|
||||||
|
grid: number;
|
||||||
|
finish: number;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
type Timing = { id: string; position: number; seconds: number };
|
||||||
|
type RaceData = {
|
||||||
|
race: { title: string; year: number; date: string; circuit: string; location: string; laps: number };
|
||||||
|
drivers: Driver[];
|
||||||
|
laps: { number: number; timings: Timing[] }[];
|
||||||
|
pitStops: { id: string; lap: number; seconds: number }[];
|
||||||
|
events: { lap: number; kind: string; title: string; description: string }[];
|
||||||
|
track: [number, number][];
|
||||||
|
corners: { number: number; x: number; y: number }[];
|
||||||
|
drsZones: { number: number; track: [number, number][] }[];
|
||||||
|
sources: Record<string, string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
const data = rawData as unknown as RaceData;
|
||||||
|
const driverById = new Map(data.drivers.map((driver) => [driver.id, driver]));
|
||||||
|
const trackSegments = data.track.slice(1).map((point, index) => Math.hypot(point[0] - data.track[index]![0], point[1] - data.track[index]![1]));
|
||||||
|
const trackDistances = [0];
|
||||||
|
for (const segment of trackSegments) trackDistances.push(trackDistances.at(-1)! + segment);
|
||||||
|
const trackLength = trackDistances.at(-1)!;
|
||||||
|
const trackPath = data.track.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" ");
|
||||||
|
const drsPaths = data.drsZones.map((zone) => ({
|
||||||
|
number: zone.number,
|
||||||
|
path: zone.track.map(([x, y], index) => `${index ? "L" : "M"}${x} ${y}`).join(" "),
|
||||||
|
middle: zone.track[Math.floor(zone.track.length / 2)]!,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const elapsedByDriver = new Map<string, number[]>();
|
||||||
|
for (const driver of data.drivers) elapsedByDriver.set(driver.id, [0]);
|
||||||
|
for (const lap of data.laps) {
|
||||||
|
for (const timing of lap.timings) {
|
||||||
|
const elapsed = elapsedByDriver.get(timing.id)!;
|
||||||
|
elapsed.push(Math.round((elapsed.at(-1)! + timing.seconds) * 1000) / 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const leaderCrossings = [0];
|
||||||
|
for (let lap = 1; lap <= data.race.laps; lap++) {
|
||||||
|
leaderCrossings.push(Math.min(...[...elapsedByDriver.values()].map((times) => times[lap] ?? Infinity)));
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointOnTrack(progress: number): [number, number] {
|
||||||
|
const distance = (((progress % 1) + 1) % 1) * trackLength;
|
||||||
|
let low = 0;
|
||||||
|
let high = trackSegments.length - 1;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = (low + high) >> 1;
|
||||||
|
if (trackDistances[middle + 1]! < distance) low = middle + 1;
|
||||||
|
else high = middle;
|
||||||
|
}
|
||||||
|
const fraction = (distance - trackDistances[low]!) / (trackSegments[low] ?? 1);
|
||||||
|
const start = data.track[low]!;
|
||||||
|
const end = data.track[low + 1]!;
|
||||||
|
return [start[0] + (end[0] - start[0]) * fraction, start[1] + (end[1] - start[1]) * fraction];
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(seconds: number) {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = Math.floor(seconds % 60);
|
||||||
|
return `${hours ? `${hours}:` : ""}${String(minutes).padStart(hours ? 2 : 1, "0")}:${String(secs).padStart(2, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatLapTime(seconds: number) {
|
||||||
|
return `${Math.floor(seconds / 60)}:${(seconds % 60).toFixed(3).padStart(6, "0")}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function inactiveLabel(driver: Driver) {
|
||||||
|
if (driver.status === "Did not start") return "DNS";
|
||||||
|
if (driver.status === "Disqualified") return "DSQ";
|
||||||
|
return "OUT";
|
||||||
|
}
|
||||||
|
|
||||||
|
function positionAtLap(driverId: string, lap: number) {
|
||||||
|
if (lap === 0) {
|
||||||
|
const driver = driverById.get(driverId);
|
||||||
|
return driver?.status === "Did not start" ? null : driver?.grid ?? null;
|
||||||
|
}
|
||||||
|
return data.laps[lap - 1]?.timings.find((timing) => timing.id === driverId)?.position ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function carProgress(driverId: string, seconds: number, grid: number) {
|
||||||
|
const times = elapsedByDriver.get(driverId)!;
|
||||||
|
if (seconds > times.at(-1)! + 2) return null;
|
||||||
|
let low = 0;
|
||||||
|
let high = times.length - 1;
|
||||||
|
while (low < high) {
|
||||||
|
const middle = Math.ceil((low + high) / 2);
|
||||||
|
if (times[middle]! <= seconds) low = middle;
|
||||||
|
else high = middle - 1;
|
||||||
|
}
|
||||||
|
if (low >= times.length - 1) return seconds >= times.at(-1)! ? 0 : null;
|
||||||
|
const fraction = (seconds - times[low]!) / (times[low + 1]! - times[low]!);
|
||||||
|
const gridOffset = Math.max(0, 1 - seconds / 12) * grid * 0.008;
|
||||||
|
return fraction - gridOffset;
|
||||||
|
}
|
||||||
|
|
||||||
|
function PositionChart({ driver }: { driver: Driver }) {
|
||||||
|
const positions = [driver.grid, ...data.laps.map((lap) => lap.timings.find((timing) => timing.id === driver.id)?.position).filter((position): position is number => position !== undefined)];
|
||||||
|
if (positions.length === 1) return <p className="replay-no-laps">No race laps recorded.</p>;
|
||||||
|
const points = positions.map((position, index) => `${(index / Math.max(positions.length - 1, 1)) * 284 + 8},${((position - 1) / 19) * 73 + 8}`).join(" ");
|
||||||
|
return <div className="replay-chart" aria-label={`${driver.name} race position by lap, from P${driver.grid} to P${driver.finish}`} role="img">
|
||||||
|
<div className="replay-chart-axis"><span>P1</span><span>P20</span></div>
|
||||||
|
<svg viewBox="0 0 300 90" preserveAspectRatio="none" aria-hidden="true">
|
||||||
|
<line x1="8" x2="292" y1="8" y2="8" />
|
||||||
|
<line x1="8" x2="292" y1="81" y2="81" />
|
||||||
|
<polyline points={points} fill="none" stroke={driver.color} strokeWidth="2.6" strokeLinecap="round" strokeLinejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div className="replay-chart-bottom"><span>START</span><span>FINISH</span></div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RaceReplay() {
|
||||||
|
const [progress, setProgress] = useState(0);
|
||||||
|
const [playing, setPlaying] = useState(false);
|
||||||
|
const [speed, setSpeed] = useState(1);
|
||||||
|
const [selectedId, setSelectedId] = useState("max_verstappen");
|
||||||
|
const [showAllCars, setShowAllCars] = useState(true);
|
||||||
|
const progressRef = useRef(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!playing) return;
|
||||||
|
let frame = 0;
|
||||||
|
let last = 0;
|
||||||
|
function tick(now: number) {
|
||||||
|
if (last && now - last >= 35) {
|
||||||
|
const elapsed = Math.min(now - last, 100);
|
||||||
|
progressRef.current = Math.min(data.race.laps, progressRef.current + elapsed / 1000 * 0.8 * speed);
|
||||||
|
setProgress(progressRef.current);
|
||||||
|
if (progressRef.current >= data.race.laps) setPlaying(false);
|
||||||
|
last = now;
|
||||||
|
} else if (!last) last = now;
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
}
|
||||||
|
frame = requestAnimationFrame(tick);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [playing, speed]);
|
||||||
|
|
||||||
|
const completedLap = Math.min(data.race.laps, Math.floor(progress));
|
||||||
|
const displayLap = Math.min(data.race.laps, completedLap + (progress < data.race.laps ? 1 : 0));
|
||||||
|
const lapFraction = progress - completedLap;
|
||||||
|
const elapsed = completedLap === data.race.laps
|
||||||
|
? leaderCrossings.at(-1)!
|
||||||
|
: leaderCrossings[completedLap]! + (leaderCrossings[completedLap + 1]! - leaderCrossings[completedLap]!) * lapFraction;
|
||||||
|
const selected = driverById.get(selectedId)!;
|
||||||
|
const selectedStatus = inactiveLabel(selected);
|
||||||
|
const selectedPosition = positionAtLap(selectedId, completedLap);
|
||||||
|
const activeEvent = [...data.events].reverse().find((event) => event.lap <= displayLap);
|
||||||
|
const lapTimings = completedLap ? data.laps[completedLap - 1]!.timings : [];
|
||||||
|
const timingById = new Map(lapTimings.map((timing) => [timing.id, timing]));
|
||||||
|
const orderedDrivers = [...data.drivers].sort((a, b) => {
|
||||||
|
if (!completedLap && a.status === "Did not start") return b.status === "Did not start" ? a.grid - b.grid : 1;
|
||||||
|
if (!completedLap && b.status === "Did not start") return -1;
|
||||||
|
const aPosition = timingById.get(a.id)?.position;
|
||||||
|
const bPosition = timingById.get(b.id)?.position;
|
||||||
|
if (aPosition !== undefined && bPosition !== undefined) return aPosition - bPosition;
|
||||||
|
if (aPosition !== undefined) return -1;
|
||||||
|
if (bPosition !== undefined) return 1;
|
||||||
|
return completedLap ? a.finish - b.finish : a.grid - b.grid;
|
||||||
|
});
|
||||||
|
const visibleCars = useMemo(() => data.drivers.filter((driver) => driver.status !== "Did not start" && (showAllCars || driver.id === selectedId)), [showAllCars, selectedId]);
|
||||||
|
const selectedPits = data.pitStops.filter((pit) => pit.id === selectedId);
|
||||||
|
const validLaps = data.laps.flatMap((lap) => lap.timings.filter((timing) => timing.id === selectedId && timing.seconds < 200).map((timing) => timing.seconds));
|
||||||
|
const bestLap = validLaps.length ? Math.min(...validLaps) : null;
|
||||||
|
|
||||||
|
function jumpTo(lap: number) {
|
||||||
|
setPlaying(false);
|
||||||
|
progressRef.current = Math.max(0, Math.min(data.race.laps, lap));
|
||||||
|
setProgress(progressRef.current);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <div className="race-replay">
|
||||||
|
<div className="replay-wrap">
|
||||||
|
<header className="replay-intro">
|
||||||
|
<div className="replay-intro-copy">
|
||||||
|
<div className="replay-eyebrow"><span className="replay-live-dot" /> RACE REPLAY <span className="replay-eyebrow-divider">/</span> 2024 ARCHIVE <span className="replay-eyebrow-divider">/</span> ROUND 21</div>
|
||||||
|
<h1>São Paulo <em>’24</em><span className="replay-title-period">.</span></h1>
|
||||||
|
<p>One very wet Sunday at Interlagos. Scrub the race, follow a driver, and watch the order change lap by lap.</p>
|
||||||
|
<div className="replay-intro-meta"><span><FlagTriangleRight size={15} /> INTERLAGOS, BRAZIL</span><span><CloudRain size={16} /> WET RACE</span><span>03 NOV 2024</span></div>
|
||||||
|
</div>
|
||||||
|
<div className="replay-headline-stat"><span>THE COMEBACK</span><strong><small>P</small>17 <i>→</i> <small>P</small>1</strong><p>Max Verstappen · Red Bull Racing</p></div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div className="replay-dashboard">
|
||||||
|
<section className="replay-map-panel" aria-labelledby="map-title">
|
||||||
|
<div className="replay-panel-heading"><div><p>01 / CIRCUIT VIEW</p><h2 id="map-title">Autódromo José Carlos Pace</h2></div><span className="replay-track-tag">4.309 KM · ANTI-CLOCKWISE</span></div>
|
||||||
|
<div className="replay-map-stage">
|
||||||
|
<div className="replay-stage-top"><span>INTERLAGOS / SÃO PAULO</span><span>24° 42′ S 46° 41′ W</span></div>
|
||||||
|
<svg className="replay-circuit" viewBox="210 0 580 740" role="img" aria-label={`FIA-oriented Interlagos circuit map with two DRS zones and cars at lap ${displayLap}`}>
|
||||||
|
<defs><filter id="car-glow" x="-200%" y="-200%" width="500%" height="500%"><feGaussianBlur stdDeviation="8" /></filter></defs>
|
||||||
|
<path d={trackPath} className="replay-track-shadow" />
|
||||||
|
<path d={trackPath} className="replay-track-outer" />
|
||||||
|
{drsPaths.map((zone) => <g key={zone.number} className="replay-drs-zone">
|
||||||
|
<path d={zone.path} />
|
||||||
|
<text x={zone.middle[0] + (zone.number === 1 ? 29 : -29)} y={zone.middle[1]} textAnchor={zone.number === 1 ? "start" : "end"}>DRS {zone.number}</text>
|
||||||
|
</g>)}
|
||||||
|
{data.corners.filter((corner) => [1, 3, 4, 6, 8, 12, 13, 15].includes(corner.number)).map((corner) => <g key={corner.number} className="replay-corner" transform={`translate(${corner.x} ${corner.y})`}><circle r="4" /><text x="12" y="-10">T{corner.number}</text></g>)}
|
||||||
|
{visibleCars.map((driver) => {
|
||||||
|
const onTrack = carProgress(driver.id, elapsed, driver.grid);
|
||||||
|
if (onTrack === null) return null;
|
||||||
|
const [x, y] = pointOnTrack(onTrack);
|
||||||
|
const focused = driver.id === selectedId;
|
||||||
|
return <g key={driver.id} transform={`translate(${x} ${y})`} className={focused ? "replay-car is-focused" : "replay-car"}>
|
||||||
|
{focused && <circle r="19" fill={driver.color} opacity=".35" filter="url(#car-glow)" />}
|
||||||
|
<circle r={focused ? 9 : 5.5} fill={driver.color} stroke="#15151e" strokeWidth={focused ? 2 : 1.5} />
|
||||||
|
{focused && <text y="-23" textAnchor="middle">{driver.code}</text>}
|
||||||
|
</g>;
|
||||||
|
})}
|
||||||
|
<g transform={`translate(${data.track[0]![0]} ${data.track[0]![1]})`} className="replay-start-marker"><rect x="-3" y="-14" width="6" height="28" /><text x="20" y="-15">START / FINISH</text></g>
|
||||||
|
</svg>
|
||||||
|
<div className="replay-map-legend"><span><i /> SELECTED DRIVER</span><span className="replay-drs-legend"><i /> DRS ZONES · DISABLED IN WET RACE</span><button type="button" onClick={() => setShowAllCars((value) => !value)} aria-pressed={showAllCars}>{showAllCars ? "SHOWING ALL CARS" : "SHOW ALL CARS"}</button></div>
|
||||||
|
{activeEvent && <div className={`replay-stage-event replay-stage-event--${activeEvent.kind}`}><span>LAP {String(activeEvent.lap).padStart(2, "0")}</span><strong>{activeEvent.title}</strong></div>}
|
||||||
|
</div>
|
||||||
|
<div className="replay-playback">
|
||||||
|
<div className="replay-playback-top"><div><span>RACE CLOCK</span><strong>{formatTime(elapsed)}</strong></div><div className="replay-lap-readout"><span>CURRENT LAP</span><strong>{String(displayLap).padStart(2, "0")} <small>/ {data.race.laps}</small></strong></div></div>
|
||||||
|
<div className="replay-transport">
|
||||||
|
<button className="replay-play-button" type="button" onClick={() => { if (progress >= data.race.laps) { progressRef.current = 0; setProgress(0); } setPlaying((value) => !value); }} aria-label={playing ? "Pause replay" : "Play replay"}>{playing ? <Pause fill="currentColor" size={20} /> : <Play fill="currentColor" size={20} />}</button>
|
||||||
|
<button className="replay-step-button" type="button" onClick={() => jumpTo(Math.floor(progress) - 1)} aria-label="Previous lap"><ChevronLeft size={21} /></button>
|
||||||
|
<div className="replay-scrubber"><input type="range" min="0" max={data.race.laps} step="0.05" value={progress} onChange={(event) => { setPlaying(false); progressRef.current = Number(event.target.value); setProgress(progressRef.current); }} aria-label="Replay lap" style={{ "--replay-progress": `${progress / data.race.laps * 100}%` } as React.CSSProperties} /><div className="replay-timeline-ticks" aria-hidden="true">{data.events.map((event) => <span key={`${event.lap}-${event.kind}`} className={`is-${event.kind}`} style={{ left: `${event.lap / data.race.laps * 100}%` }} />)}</div></div>
|
||||||
|
<button className="replay-step-button" type="button" onClick={() => jumpTo(Math.floor(progress) + 1)} aria-label="Next lap"><ChevronRight size={21} /></button>
|
||||||
|
<button className="replay-reset-button" type="button" onClick={() => jumpTo(0)} aria-label="Reset replay"><RotateCcw size={18} /></button>
|
||||||
|
<div className="replay-speed" aria-label="Playback speed">{[1, 2, 4].map((option) => <button key={option} type="button" aria-pressed={speed === option} onClick={() => setSpeed(option)}>{option}×</button>)}</div>
|
||||||
|
</div>
|
||||||
|
<p>Cars are interpolated from recorded lap times on an OpenF1 circuit trace. Order updates at each timing line; dots are not live GPS.</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="replay-sidebar" aria-label="Race classification and driver details">
|
||||||
|
<div className="replay-sidebar-head"><span>02 / LIVE ORDER</span><strong>LAP {String(displayLap).padStart(2, "0")}</strong></div>
|
||||||
|
<div className="replay-leaderboard">
|
||||||
|
{orderedDrivers.map((driver) => {
|
||||||
|
const position = driver.status === "Did not start" ? undefined : completedLap ? timingById.get(driver.id)?.position : driver.grid;
|
||||||
|
const change = position ? driver.grid - position : 0;
|
||||||
|
return <button key={driver.id} type="button" className={`replay-driver-row ${driver.id === selectedId ? "is-selected" : ""} ${!position ? "is-out" : ""}`} onClick={() => setSelectedId(driver.id)} aria-pressed={driver.id === selectedId}>
|
||||||
|
<span className="replay-row-position">{position ? String(position).padStart(2, "0") : "—"}</span>
|
||||||
|
<span className="replay-row-color" style={{ backgroundColor: driver.color }} />
|
||||||
|
<span className="replay-row-identity"><strong>{driver.code}</strong><small>{driver.name}</small></span>
|
||||||
|
<span className={`replay-row-change ${change > 0 ? "is-up" : change < 0 ? "is-down" : ""}`}>{position ? (change > 0 ? `+${change}` : change) : inactiveLabel(driver)}</span>
|
||||||
|
</button>;
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
<div className="replay-driver-focus"><p>DRIVER FOCUS <span>#{selected.number}</span></p><div className="replay-focus-head"><div><h3>{selected.name}</h3><span>{selected.team}</span></div><span className="replay-focus-position" style={{ color: selected.color }}>{selectedPosition ? `P${selectedPosition}` : selectedStatus}</span></div><div className="replay-focus-stats"><div><span>GRID</span><strong>P{selected.grid}</strong></div><div><span>NOW</span><strong>{selectedPosition ? `P${selectedPosition}` : selectedStatus}</strong></div><div><span>BEST LAP</span><strong>{bestLap === null ? "—" : formatLapTime(bestLap)}</strong></div><div><span>PIT STOPS</span><strong>{selectedPits.length}</strong></div></div><PositionChart driver={selected} /></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="replay-moments" aria-labelledby="moments-title"><div className="replay-moments-title"><div><p>03 / RACE CONTROL</p><h2 id="moments-title">The moments that moved it.</h2></div><span>SELECT A MOMENT TO JUMP</span></div><div className="replay-moments-track">{data.events.map((event) => <button type="button" key={`${event.lap}-${event.title}`} className={`replay-moment ${displayLap === event.lap ? "is-active" : ""}`} onClick={() => jumpTo(event.kind === "finish" ? data.race.laps : event.lap - 0.01)}><span>LAP {String(event.lap).padStart(2, "0")} <i className={`is-${event.kind}`} /></span><strong>{event.title}</strong><p>{event.description}</p><span className="replay-moment-arrow">↗</span></button>)}</div></section>
|
||||||
|
<div className="replay-sources"><span>RACE REPLAY / AN INDEPENDENT EXPERIMENT BY SEAN O’CONNOR</span><div>DATA: <a href={data.sources.laps} target="_blank" rel="noreferrer">JOLPICA <ArrowUpRight size={12} /></a> <a href={data.sources.track} target="_blank" rel="noreferrer">OPENF1 <ArrowUpRight size={12} /></a> <a href={data.sources.corners} target="_blank" rel="noreferrer">MULTIVIEWER <ArrowUpRight size={12} /></a> <a href={data.sources.circuitMap} target="_blank" rel="noreferrer">FIA MAP <ArrowUpRight size={12} /></a></div></div>
|
||||||
|
</div>
|
||||||
|
</div>;
|
||||||
|
}
|
||||||
@@ -8,7 +8,7 @@ export function SiteFrame({ children }: React.PropsWithChildren) {
|
|||||||
<>
|
<>
|
||||||
<main
|
<main
|
||||||
id="main-content"
|
id="main-content"
|
||||||
className={pathname === "/coffee" ? "coffee-site-main" : pathname === "/" ? "site-main" : "site-main interior-page"}
|
className={pathname === "/coffee" ? "coffee-site-main" : pathname === "/race-replay" ? "race-site-main" : pathname === "/" ? "site-main" : "site-main interior-page"}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
+3
-3
@@ -482,7 +482,7 @@ export const projects: Project[] = [
|
|||||||
tags: ["Next.js", "TypeScript", "S3", "Image processing"],
|
tags: ["Next.js", "TypeScript", "S3", "Image processing"],
|
||||||
websiteLink: "https://ma.hadlock.tech",
|
websiteLink: "https://ma.hadlock.tech",
|
||||||
image: "/portfolio/manyangles.svg",
|
image: "/portfolio/manyangles.svg",
|
||||||
imageAlt: "Manyangles event photo sharing",
|
imageAlt: "Manyangles shared albums",
|
||||||
featured: true,
|
featured: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -521,9 +521,9 @@ export const projects: Project[] = [
|
|||||||
"Hardware Telemetry",
|
"Hardware Telemetry",
|
||||||
],
|
],
|
||||||
gitLink: "https://git.soconnor.dev/soconnor/Puter",
|
gitLink: "https://git.soconnor.dev/soconnor/Puter",
|
||||||
image: "/images/puter.jpg",
|
image: "/portfolio/puter/01-processes-light.png",
|
||||||
imageAlt:
|
imageAlt:
|
||||||
"Puter's macOS performance dashboard showing CPU, memory, and disk utilization",
|
"Puter's macOS process manager with live CPU, memory, disk, and network activity",
|
||||||
featured: true,
|
featured: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -20,6 +20,7 @@ export const siteGroups = [
|
|||||||
label: "Beyond work",
|
label: "Beyond work",
|
||||||
items: [
|
items: [
|
||||||
{ href: "/coffee", label: "Coffee map" },
|
{ href: "/coffee", label: "Coffee map" },
|
||||||
|
{ href: "/race-replay", label: "Race Replay" },
|
||||||
{ href: "/travel", label: "Trips & interests" },
|
{ href: "/travel", label: "Trips & interests" },
|
||||||
{ href: "/october", label: "October, forever" },
|
{ href: "/october", label: "October, forever" },
|
||||||
],
|
],
|
||||||
|
|||||||
+167
-76
@@ -489,17 +489,6 @@ button {
|
|||||||
height: 100% !important;
|
height: 100% !important;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
.studio-product-logo {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
background: #0004;
|
|
||||||
}
|
|
||||||
.studio-product-logo img {
|
|
||||||
width: 43%;
|
|
||||||
height: auto;
|
|
||||||
}
|
|
||||||
.studio-project-title {
|
.studio-project-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1002,33 +991,119 @@ button {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
padding: 4.5%;
|
||||||
|
background: radial-gradient(circle at 85% 10%, #313d65 0, #1d2339 42%, #151923 100%);
|
||||||
}
|
}
|
||||||
.manyangles-preview > img {
|
.manyangles-demo {
|
||||||
object-fit: cover;
|
|
||||||
object-position: 50% 40%;
|
|
||||||
}
|
|
||||||
.manyangles-lockup {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
flex-direction: column;
|
||||||
gap: 10px;
|
height: 100%;
|
||||||
padding: 6%;
|
overflow: hidden;
|
||||||
color: white;
|
border: 1px solid #ffffff22;
|
||||||
background: linear-gradient(transparent 45%, #101e33b3);
|
border-radius: 10px;
|
||||||
|
background: #1b1e2a;
|
||||||
|
box-shadow: 0 14px 35px #05091460;
|
||||||
|
color: #f5f5f7;
|
||||||
|
font-family: Arial, Helvetica, sans-serif;
|
||||||
}
|
}
|
||||||
.manyangles-lockup svg {
|
.manyangles-demo-bar {
|
||||||
width: 9%;
|
display: flex;
|
||||||
max-width: 42px;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 13%;
|
||||||
|
padding: 0 4%;
|
||||||
|
border-bottom: 1px solid #ffffff1a;
|
||||||
|
font-size: clamp(8px, .9vw, 12px);
|
||||||
}
|
}
|
||||||
.manyangles-lockup span {
|
.manyangles-demo-bar-title,
|
||||||
|
.manyangles-demo-share,
|
||||||
|
.manyangles-demo-bottom > span:first-child {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.manyangles-demo-bar-title { font-weight: 700; }
|
||||||
|
.manyangles-demo-share { color: #b8c6ff; }
|
||||||
|
.manyangles-demo-content {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 5% 6% 4%;
|
||||||
|
}
|
||||||
|
.manyangles-demo-intro > span {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 2.5%;
|
||||||
|
color: #a7b7f3;
|
||||||
|
font-size: clamp(7px, .7vw, 10px);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-variation-settings: "SHRP" 70;
|
letter-spacing: .18em;
|
||||||
font-size: clamp(24px, 3vw, 36px);
|
}
|
||||||
letter-spacing: -0.04em;
|
.manyangles-demo-intro strong {
|
||||||
|
display: block;
|
||||||
|
font-size: clamp(17px, 2.1vw, 31px);
|
||||||
|
letter-spacing: -.035em;
|
||||||
line-height: 1.1;
|
line-height: 1.1;
|
||||||
}
|
}
|
||||||
|
.manyangles-demo-intro p {
|
||||||
|
margin: 2.2% 0 0;
|
||||||
|
color: #b9bdca;
|
||||||
|
font-size: clamp(8px, .9vw, 12px);
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
.manyangles-demo-photos {
|
||||||
|
display: grid;
|
||||||
|
flex: 1;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 2%;
|
||||||
|
min-height: 0;
|
||||||
|
margin-top: 5%;
|
||||||
|
}
|
||||||
|
.manyangles-demo-photo {
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 5px;
|
||||||
|
background: linear-gradient(165deg, var(--tile-sky), var(--tile-horizon));
|
||||||
|
}
|
||||||
|
.manyangles-demo-photo::before,
|
||||||
|
.manyangles-demo-photo::after {
|
||||||
|
position: absolute;
|
||||||
|
content: "";
|
||||||
|
inset: auto -20% -12%;
|
||||||
|
height: 65%;
|
||||||
|
border-radius: 48% 52% 0 0;
|
||||||
|
background: var(--tile-hill);
|
||||||
|
transform: rotate(-11deg);
|
||||||
|
}
|
||||||
|
.manyangles-demo-photo::after {
|
||||||
|
inset: auto -25% -35%;
|
||||||
|
height: 70%;
|
||||||
|
background: var(--tile-foreground);
|
||||||
|
transform: rotate(14deg);
|
||||||
|
}
|
||||||
|
.manyangles-demo-photo span {
|
||||||
|
position: absolute;
|
||||||
|
top: 18%;
|
||||||
|
right: 19%;
|
||||||
|
width: 17%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--tile-sun);
|
||||||
|
box-shadow: 0 0 20px 5px var(--tile-sun);
|
||||||
|
}
|
||||||
|
.manyangles-demo-photo--one { --tile-sky: #f0baa3; --tile-horizon: #e5d8c6; --tile-hill: #769b99; --tile-foreground: #3d646f; --tile-sun: #fff0b8; }
|
||||||
|
.manyangles-demo-photo--two { --tile-sky: #a7bbd9; --tile-horizon: #d8d2bb; --tile-hill: #81948c; --tile-foreground: #576d6d; --tile-sun: #f8e0ae; }
|
||||||
|
.manyangles-demo-photo--three { --tile-sky: #d9b5be; --tile-horizon: #e8cdb6; --tile-hill: #9f998c; --tile-foreground: #6b797a; --tile-sun: #fff2ce; }
|
||||||
|
.manyangles-demo-photo--four { --tile-sky: #9eb6cf; --tile-horizon: #cddbd5; --tile-hill: #6c958c; --tile-foreground: #3f6c70; --tile-sun: #f6e9bf; }
|
||||||
|
.manyangles-demo-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 4%;
|
||||||
|
color: #b9bdca;
|
||||||
|
font-size: clamp(7px, .75vw, 10px);
|
||||||
|
}
|
||||||
|
|
||||||
.type-study-controls {
|
.type-study-controls {
|
||||||
padding: 22px 0;
|
padding: 22px 0;
|
||||||
@@ -2745,17 +2820,6 @@ body {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Shared product branding over the existing product imagery. */
|
|
||||||
.studio-product-logo {
|
|
||||||
background: radial-gradient(ellipse at center, #071c2660, #071c261c 75%);
|
|
||||||
}
|
|
||||||
.studio-product-logo img {
|
|
||||||
filter: drop-shadow(0 2px 3px #0009) drop-shadow(0 8px 18px #0008);
|
|
||||||
}
|
|
||||||
.beenvoice-logo img {
|
|
||||||
width: 55%;
|
|
||||||
filter: brightness(0) invert(1) drop-shadow(0 2px 3px #0009) drop-shadow(0 8px 18px #0008);
|
|
||||||
}
|
|
||||||
.mobile-menu-label,
|
.mobile-menu-label,
|
||||||
.primary-links .header-dropdown .mobile-menu-link {
|
.primary-links .header-dropdown .mobile-menu-link {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -2794,18 +2858,6 @@ body {
|
|||||||
.nav-inner, .primary-links { gap: 8px; }
|
.nav-inner, .primary-links { gap: 8px; }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Brand marks share a centered, shadowed treatment. */
|
|
||||||
.manyangles-lockup {
|
|
||||||
inset: 0;
|
|
||||||
justify-content: center;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0;
|
|
||||||
background: radial-gradient(ellipse at center, #071c2660, #071c261c 75%);
|
|
||||||
}
|
|
||||||
.manyangles-lockup svg,
|
|
||||||
.manyangles-lockup span {
|
|
||||||
filter: drop-shadow(0 2px 3px #0009) drop-shadow(0 8px 18px #0008);
|
|
||||||
}
|
|
||||||
.hadlock-brand img { width: min(100%, 340px); height: auto; }
|
.hadlock-brand img { width: min(100%, 340px); height: auto; }
|
||||||
|
|
||||||
/* Personal postcards: photographs, editorial captions, a playful calendar. */
|
/* Personal postcards: photographs, editorial captions, a playful calendar. */
|
||||||
@@ -2942,28 +2994,17 @@ body {
|
|||||||
--accent-foreground: 199 47% 20%;
|
--accent-foreground: 199 47% 20%;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* One proportional logo height, independent of each wordmark's width. */
|
/* Brand identity sits in the caption below each unobstructed project image. */
|
||||||
.studio-project-image {
|
.project-brand { display: inline-flex; align-items: center; gap: 8px; min-height: 30px; max-width: 100%; line-height: 1; }
|
||||||
container-type: inline-size;
|
.project-brand > img { display: block; flex: none; width: auto; height: 27px; max-width: min(45vw, 190px); object-fit: contain; }
|
||||||
--project-logo-height: 8cqw;
|
.project-brand--racetix > img { filter: brightness(0); }
|
||||||
}
|
.project-brand--beenvoice > img { height: 25px; filter: brightness(0); }
|
||||||
.studio-product-logo img,
|
.project-brand--puter > img { width: 30px; height: 30px; border-radius: 7px; }
|
||||||
.beenvoice-logo img {
|
.project-brand--manyangles { font-size: 24px; font-weight: 700; letter-spacing: -.04em; }
|
||||||
width: auto;
|
.project-brand--manyangles svg { flex: none; width: 29px; height: 29px; }
|
||||||
height: var(--project-logo-height);
|
@media (prefers-color-scheme: dark) {
|
||||||
max-width: 84%;
|
.project-brand--racetix > img,
|
||||||
object-fit: contain;
|
.project-brand--beenvoice > img { filter: brightness(0) invert(1); }
|
||||||
}
|
|
||||||
.manyangles-lockup { gap: 2cqw; }
|
|
||||||
.manyangles-lockup svg {
|
|
||||||
width: var(--project-logo-height);
|
|
||||||
height: var(--project-logo-height);
|
|
||||||
max-width: none;
|
|
||||||
}
|
|
||||||
.manyangles-lockup span {
|
|
||||||
/* Match visible capital height to the SVG wordmarks. */
|
|
||||||
font-size: 10.5cqw;
|
|
||||||
line-height: 1;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Contact and footer use a single shared backdrop. */
|
/* Contact and footer use a single shared backdrop. */
|
||||||
@@ -2977,6 +3018,56 @@ body {
|
|||||||
.archive-image > img { object-fit: contain; object-position: center; }
|
.archive-image > img { object-fit: contain; object-position: center; }
|
||||||
.archive-image--photo > img { object-fit: cover; }
|
.archive-image--photo > img { object-fit: cover; }
|
||||||
|
|
||||||
|
/* Puter's real macOS captures, paired with its compiled app icon. */
|
||||||
|
.puter .studio-project-image { background: #d7e8df; }
|
||||||
|
.puter-preview {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: radial-gradient(circle at 80% 5%, #eff9f1, #c6dfd0 85%);
|
||||||
|
}
|
||||||
|
.puter-preview--dark { background: radial-gradient(circle at 80% 5%, #31574a, #11332a 85%); }
|
||||||
|
.puter-preview .puter-preview-screen,
|
||||||
|
.archive-image .puter-preview .puter-preview-screen {
|
||||||
|
position: absolute !important;
|
||||||
|
inset: 7% 5% auto !important;
|
||||||
|
width: 90% !important;
|
||||||
|
height: 86% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
border: 1px solid #28564222;
|
||||||
|
border-radius: 9px;
|
||||||
|
object-fit: cover !important;
|
||||||
|
object-position: top center;
|
||||||
|
box-shadow: 0 15px 28px #15352830;
|
||||||
|
}
|
||||||
|
.puter-preview--interactive .puter-preview-screen,
|
||||||
|
.archive-image .puter-preview--interactive .puter-preview-screen { height: 76% !important; }
|
||||||
|
.puter-preview-controls {
|
||||||
|
position: absolute;
|
||||||
|
right: 4%;
|
||||||
|
bottom: 4%;
|
||||||
|
left: 4%;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 5px;
|
||||||
|
overflow-x: auto;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
.puter-preview-controls button {
|
||||||
|
flex: none;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border: 1px solid #52756755;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: #ffffffb8;
|
||||||
|
color: #174634;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.2;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.puter-preview-controls button[aria-pressed="true"] { background: #0c5d40; border-color: #0c5d40; color: #fff; }
|
||||||
|
.puter-preview--dark .puter-preview-controls button { background: #173a2f; color: #effcf3; }
|
||||||
|
.puter-preview--dark .puter-preview-controls button[aria-pressed="true"] { background: #c0efd3; color: #0a3021; }
|
||||||
|
|
||||||
/* September 10's full-width mobile header panel. */
|
/* September 10's full-width mobile header panel. */
|
||||||
.restored-header-menu {
|
.restored-header-menu {
|
||||||
width: 100vw;
|
width: 100vw;
|
||||||
|
|||||||
Reference in New Issue
Block a user