Docker Image and first iteration
This commit is contained in:
1 parent
723b64b1c4
commit
bd9209ebb7
40 files changed
+26643
No files matched your search
Generated
+2383
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"name": "xav-portfolio-map",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-simple-maps": "3.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "16.3.2",
|
||||
"@types/react": "18.3.30",
|
||||
"@types/react-dom": "18.3.7",
|
||||
"@types/react-simple-maps": "3.0.6",
|
||||
"@vitejs/plugin-react": "6.0.2",
|
||||
"jsdom": "29.1.1",
|
||||
"typescript": "6.0.3",
|
||||
"vite": "8.0.16",
|
||||
"vitest": "4.1.8"
|
||||
},
|
||||
"overrides": {
|
||||
"d3-color": "3.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PortfolioMap } from "./PortfolioMap";
|
||||
|
||||
vi.mock("react-simple-maps", () => ({
|
||||
ComposableMap: ({ children }: { children: React.ReactNode }) => <svg>{children}</svg>,
|
||||
Geographies: ({
|
||||
children,
|
||||
geography,
|
||||
}: {
|
||||
children: (value: { geographies: unknown[] }) => React.ReactNode;
|
||||
geography: string;
|
||||
}) =>
|
||||
children({
|
||||
geographies: geography.includes("subdivisions")
|
||||
? [{ rsmKey: "us-ca", properties: { code: "US-CA", name: "California" } }]
|
||||
: [],
|
||||
}),
|
||||
Geography: (props: React.SVGProps<SVGPathElement>) => <path {...props} />,
|
||||
}));
|
||||
|
||||
describe("PortfolioMap", () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
regions: [
|
||||
{ code: "USA", name: "United States of America", level: "country", parentCode: null, entries: [] },
|
||||
{ code: "US-CA", name: "California", level: "subdivision", parentCode: "USA", entries: [] },
|
||||
],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("selects a region and renders its empty state", async () => {
|
||||
render(<PortfolioMap />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("California")).toBeTruthy());
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "US-CA" } });
|
||||
|
||||
expect(screen.getByText("No published experience is attached to this region yet.")).toBeTruthy();
|
||||
expect(screen.queryByRole("button", { name: "View United States of America" })).toBeNull();
|
||||
});
|
||||
|
||||
it("clears a pinned region", async () => {
|
||||
render(<PortfolioMap />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText("California")).toBeTruthy());
|
||||
fireEvent.change(screen.getByRole("combobox"), { target: { value: "US-CA" } });
|
||||
fireEvent.click(screen.getByRole("button", { name: "Clear selection" }));
|
||||
|
||||
expect(screen.queryByText("No published experience is attached to this region yet.")).toBeNull();
|
||||
});
|
||||
|
||||
it("selects a map region with the keyboard", async () => {
|
||||
render(<PortfolioMap />);
|
||||
|
||||
const region = await screen.findByRole("button", { name: "California" });
|
||||
fireEvent.keyDown(region, { key: "Enter" });
|
||||
|
||||
expect(screen.getByText("No published experience is attached to this region yet.")).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,205 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ComposableMap,
|
||||
Geographies,
|
||||
Geography,
|
||||
} from "react-simple-maps";
|
||||
|
||||
import type { Region, RegionsResponse } from "./types";
|
||||
|
||||
const countriesUrl = "/static/showcase/maps/countries.topo.json";
|
||||
const subdivisionsUrl = "/static/showcase/maps/subdivisions.topo.json";
|
||||
const visibleCountryCodes = new Set([
|
||||
"ALB", "AND", "AUT", "BEL", "BGR", "BIH", "BLR", "CAN", "CHE", "CZE",
|
||||
"DEU", "DNK", "ESP", "EST", "FIN", "FRA", "GBR", "GRC", "GRL", "HRV",
|
||||
"HUN", "IRL", "ISL", "ITA", "KOS", "LTU", "LUX", "LVA", "MDA", "MEX",
|
||||
"MKD", "MNE", "NLD", "NOR", "POL", "PRT", "ROU", "RUS", "SRB", "SVK",
|
||||
"SVN", "SWE", "TUR", "UKR", "USA",
|
||||
]);
|
||||
|
||||
type GeographyShape = {
|
||||
rsmKey: string;
|
||||
properties: {
|
||||
code: string;
|
||||
name: string;
|
||||
};
|
||||
};
|
||||
|
||||
function RegionLayer({
|
||||
geographyUrl,
|
||||
regions,
|
||||
selectedCode,
|
||||
visibleCodes,
|
||||
onPreview,
|
||||
onSelect,
|
||||
}: {
|
||||
geographyUrl: string;
|
||||
regions: Map<string, Region>;
|
||||
selectedCode: string | null;
|
||||
visibleCodes?: Set<string>;
|
||||
onPreview: (region: Region | null) => void;
|
||||
onSelect: (region: Region) => void;
|
||||
}) {
|
||||
return (
|
||||
<Geographies geography={geographyUrl}>
|
||||
{({ geographies }: { geographies: GeographyShape[] }) =>
|
||||
geographies.map((geography) => {
|
||||
if (visibleCodes && !visibleCodes.has(geography.properties.code)) return null;
|
||||
|
||||
const region = regions.get(geography.properties.code);
|
||||
if (!region) return null;
|
||||
|
||||
const active = region.entries.length > 0;
|
||||
const selected = selectedCode === region.code;
|
||||
return (
|
||||
<Geography
|
||||
aria-label={region.name}
|
||||
className={`map-region${active ? " has-entries" : ""}${selected ? " is-selected" : ""}`}
|
||||
geography={geography}
|
||||
key={geography.rsmKey}
|
||||
onBlur={() => onPreview(null)}
|
||||
onClick={() => onSelect(region)}
|
||||
onFocus={() => onPreview(region)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
onSelect(region);
|
||||
}
|
||||
}}
|
||||
onMouseEnter={() => onPreview(region)}
|
||||
onMouseLeave={() => onPreview(null)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
/>
|
||||
);
|
||||
})
|
||||
}
|
||||
</Geographies>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailPanel({
|
||||
region,
|
||||
onClose,
|
||||
}: {
|
||||
region: Region | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
if (!region) return null;
|
||||
|
||||
return (
|
||||
<aside aria-live="polite" className="detail-panel">
|
||||
<button className="close-button" onClick={onClose} type="button">
|
||||
Clear selection
|
||||
</button>
|
||||
<p className="eyebrow">{region.level}</p>
|
||||
<h3>{region.name}</h3>
|
||||
{region.entries.length === 0 ? (
|
||||
<p className="empty-state">No published experience is attached to this region yet.</p>
|
||||
) : (
|
||||
<div className="entry-list">
|
||||
{region.entries.map((entry) => (
|
||||
<article className="entry-card" key={entry.id}>
|
||||
<p className="entry-type">{entry.type}</p>
|
||||
<h4>{entry.title}</h4>
|
||||
{entry.organization && <p>{entry.organization}</p>}
|
||||
<p>{entry.summary}</p>
|
||||
{entry.url && (
|
||||
<a href={entry.url} rel="noreferrer" target="_blank">
|
||||
View details
|
||||
</a>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortfolioMap() {
|
||||
const [regions, setRegions] = useState<Region[]>([]);
|
||||
const [preview, setPreview] = useState<Region | null>(null);
|
||||
const [selected, setSelected] = useState<Region | null>(null);
|
||||
const [error, setError] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetch("/api/map/regions/")
|
||||
.then((response) => {
|
||||
if (!response.ok) throw new Error("Unable to load map data");
|
||||
return response.json() as Promise<RegionsResponse>;
|
||||
})
|
||||
.then((response) => setRegions(response.regions))
|
||||
.catch(() => setError(true));
|
||||
}, []);
|
||||
|
||||
const regionsByCode = new Map(regions.map((region) => [region.code, region]));
|
||||
const visibleRegions = regions.filter(
|
||||
(region) => region.level === "subdivision" || visibleCountryCodes.has(region.code),
|
||||
);
|
||||
|
||||
if (error) {
|
||||
return <p className="map-status">The portfolio map could not be loaded.</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`map-island${selected ? " has-detail" : ""}`}>
|
||||
<div className="map-stage">
|
||||
<div className="map-toolbar">
|
||||
<div>
|
||||
<p className="eyebrow">Interactive geography</p>
|
||||
<h2 id="map-title">Atlas</h2>
|
||||
</div>
|
||||
<label>
|
||||
<span className="sr-only">Choose a region</span>
|
||||
<select
|
||||
onChange={(event) => setSelected(regionsByCode.get(event.target.value) ?? null)}
|
||||
value={selected?.code ?? ""}
|
||||
>
|
||||
<option value="">Select a region</option>
|
||||
{visibleRegions.map((region) => (
|
||||
<option key={region.code} value={region.code}>
|
||||
{region.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<div className="map-canvas">
|
||||
<ComposableMap
|
||||
height={440}
|
||||
projection="geoMercator"
|
||||
projectionConfig={{ center: [-55, 53], scale: 185 }}
|
||||
>
|
||||
<RegionLayer
|
||||
geographyUrl={countriesUrl}
|
||||
onPreview={setPreview}
|
||||
onSelect={setSelected}
|
||||
regions={regionsByCode}
|
||||
selectedCode={selected?.code ?? null}
|
||||
visibleCodes={visibleCountryCodes}
|
||||
/>
|
||||
<RegionLayer
|
||||
geographyUrl={subdivisionsUrl}
|
||||
onPreview={setPreview}
|
||||
onSelect={setSelected}
|
||||
regions={regionsByCode}
|
||||
selectedCode={selected?.code ?? null}
|
||||
/>
|
||||
</ComposableMap>
|
||||
<div className="map-preview" role="status">
|
||||
{preview ? (
|
||||
<>
|
||||
<strong>{preview.name}</strong>
|
||||
<span>{preview.entries.length} linked {preview.entries.length === 1 ? "entry" : "entries"}</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Hover or choose a region</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DetailPanel onClose={() => setSelected(null)} region={selected} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
|
||||
import { PortfolioMap } from "./PortfolioMap";
|
||||
import "./styles.css";
|
||||
|
||||
const root = document.getElementById("portfolio-map-root");
|
||||
|
||||
if (root) {
|
||||
createRoot(root).render(<PortfolioMap />);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
:root {
|
||||
color: #1e2b26;
|
||||
background: #f3efe4;
|
||||
font-family: Georgia, "Times New Roman", serif;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
max-width: 100vw;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 15%, rgba(190, 209, 196, 0.55), transparent 30rem),
|
||||
#f3efe4;
|
||||
}
|
||||
|
||||
button,
|
||||
select {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.site-header,
|
||||
.site-shell {
|
||||
margin: 0 auto;
|
||||
max-width: 1480px;
|
||||
padding-inline: clamp(1rem, 4vw, 5rem);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding-block: 1.35rem;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
height: calc(100dvh - 4.2rem);
|
||||
overflow: hidden;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.site-shell section,
|
||||
#portfolio-map-root,
|
||||
.map-island,
|
||||
.map-stage {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
h2,
|
||||
h3,
|
||||
h4,
|
||||
p {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.site-mark {
|
||||
color: #1e2b26;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.08em;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.site-mark span {
|
||||
color: #bf5b3a;
|
||||
}
|
||||
|
||||
.site-header nav {
|
||||
display: flex;
|
||||
gap: clamp(0.9rem, 3vw, 2.4rem);
|
||||
}
|
||||
|
||||
.site-header nav a {
|
||||
color: #53645d;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.72rem;
|
||||
letter-spacing: 0.12em;
|
||||
text-decoration: none;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.site-header nav a:hover,
|
||||
.site-header nav a:focus {
|
||||
color: #bf5b3a;
|
||||
}
|
||||
|
||||
.eyebrow,
|
||||
.entry-type {
|
||||
color: #bf5b3a;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.7rem;
|
||||
letter-spacing: 0.16em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.map-island {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.map-island.has-detail {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(17rem, 22rem);
|
||||
}
|
||||
|
||||
.map-stage,
|
||||
.detail-panel {
|
||||
background: rgba(255, 252, 243, 0.72);
|
||||
border: 1px solid rgba(55, 83, 72, 0.18);
|
||||
}
|
||||
|
||||
.map-stage {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.map-toolbar {
|
||||
align-items: end;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.map-toolbar h2 {
|
||||
font-size: clamp(1.8rem, 4vw, 3rem);
|
||||
letter-spacing: -0.06em;
|
||||
}
|
||||
|
||||
select {
|
||||
background: #fffaf0;
|
||||
border: 1px solid #9aa99e;
|
||||
color: #1e2b26;
|
||||
max-width: 15rem;
|
||||
padding: 0.55rem;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.map-canvas svg {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.map-region {
|
||||
cursor: pointer;
|
||||
fill: #d7ddd3;
|
||||
outline: none;
|
||||
stroke: #fffaf0;
|
||||
stroke-width: 0.5;
|
||||
transition: fill 160ms ease, stroke 160ms ease;
|
||||
}
|
||||
|
||||
.map-region.has-entries {
|
||||
fill: #91a899;
|
||||
}
|
||||
|
||||
.map-region:hover,
|
||||
.map-region:focus,
|
||||
.map-region.is-selected {
|
||||
fill: #bf5b3a;
|
||||
stroke: #71311f;
|
||||
}
|
||||
|
||||
.map-preview {
|
||||
align-items: start;
|
||||
background: rgba(255, 250, 240, 0.88);
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
font-family: "Courier New", monospace;
|
||||
font-size: 0.78rem;
|
||||
gap: 0.2rem;
|
||||
left: 0;
|
||||
padding: 0.55rem 0.7rem;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.detail-panel h3 {
|
||||
font-size: 2rem;
|
||||
letter-spacing: -0.05em;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: transparent;
|
||||
border: 1px solid #9aa99e;
|
||||
color: #1e2b26;
|
||||
cursor: pointer;
|
||||
padding: 0.45rem 0.65rem;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
float: right;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
border-top: 1px solid rgba(55, 83, 72, 0.18);
|
||||
padding-top: 0.9rem;
|
||||
}
|
||||
|
||||
.entry-card a {
|
||||
color: #9e452c;
|
||||
}
|
||||
|
||||
.empty-state,
|
||||
.map-status {
|
||||
color: #53645d;
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
height: 1px;
|
||||
margin: -1px;
|
||||
overflow: hidden;
|
||||
padding: 0;
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.site-header {
|
||||
max-width: none;
|
||||
padding-inline: 0.75rem;
|
||||
padding-block: 1rem;
|
||||
position: relative;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.site-header nav {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
grid-template-columns: repeat(3, auto);
|
||||
justify-content: end;
|
||||
position: absolute;
|
||||
right: 0.75rem;
|
||||
top: 1.45rem;
|
||||
}
|
||||
|
||||
.site-header nav a {
|
||||
font-size: 0.56rem;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.site-shell {
|
||||
height: calc(100dvh - 3.5rem);
|
||||
padding-bottom: 0.75rem;
|
||||
padding-inline: 0.75rem;
|
||||
}
|
||||
|
||||
.map-island {
|
||||
display: block;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.map-toolbar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.map-canvas {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.detail-panel {
|
||||
background: #fffaf0;
|
||||
border-radius: 1rem 1rem 0 0;
|
||||
bottom: 0;
|
||||
box-shadow: 0 -0.5rem 2rem rgba(30, 43, 38, 0.18);
|
||||
left: 0;
|
||||
max-height: 58vh;
|
||||
overflow: auto;
|
||||
position: fixed;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
export type PortfolioEntry = {
|
||||
id: number;
|
||||
type: "work" | "cv";
|
||||
title: string;
|
||||
organization: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
summary: string;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type Region = {
|
||||
code: string;
|
||||
name: string;
|
||||
level: "country" | "subdivision";
|
||||
parentCode: string | null;
|
||||
entries: PortfolioEntry[];
|
||||
};
|
||||
|
||||
export type RegionsResponse = {
|
||||
regions: Region[];
|
||||
};
|
||||
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { defineConfig } from "vitest/config";
|
||||
|
||||
export default defineConfig(({ mode }) => ({
|
||||
define: {
|
||||
"process.env.NODE_ENV": JSON.stringify(mode === "test" ? "test" : "production"),
|
||||
},
|
||||
plugins: [react()],
|
||||
build: {
|
||||
outDir: "../showcase/static/showcase/dist",
|
||||
emptyOutDir: true,
|
||||
lib: {
|
||||
entry: "src/main.tsx",
|
||||
formats: ["es"],
|
||||
fileName: () => "map.js",
|
||||
},
|
||||
rollupOptions: {
|
||||
output: {
|
||||
assetFileNames: "map.[ext]",
|
||||
},
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: "jsdom",
|
||||
globals: true,
|
||||
},
|
||||
}));
|
||||
Reference in new issue
Block a user