feat(journey): TREK Studio — a photo book designer for Journey (#1973)

* fix(photos): serve local photos root-relative so a dotted install path works

res.sendFile(absolutePath) resolves against the rewritten req.url under the Nest
ExpressAdapter and answers a spurious 404 even though the file is there — the
same trap files-download.controller.ts:60 and plugin-frame.controller.ts:74
already work around with an explicit { root } + basename. The photo resolver
never got that treatment, so every thumbnail and original 404s on any install
whose uploads path contains a dot segment (~/.local/share/trek and friends).
The public journey asset route had it too, which took shared journeys with it.

The guards above each call already confine the path to the uploads tree, so the
root form is no weaker than what it replaces.

* feat(shared): add the book document contract for TREK Studio

Millimetres throughout, not pixels: a pixel means nothing to a print shop and
changes meaning with the device pixel ratio. CSS maps 1in onto 96px, 25.4mm and
72pt with a fixed ratio, so the editor can render the document at a scale and the
print renderer at 1:1 and land on the same page.

Paint order is the array order rather than a zIndex field that can disagree with
itself, text elements may stay bound to the journal entry they came from, and a
spread can park content a layout has no room for instead of dropping it.

* feat(journey): open TREK Studio over the journey

Studio is a child route of /journey/:id and portals itself above it, so the
journey stays mounted and shows through the panel's 16px margin — it reads as
something opened on top of the journey rather than a place you navigated to. The
URL exists for reload, the back button and shared links; the user never sees it.

Every rule in studio.css is scoped under .trek-dash on purpose: dashboard.css:95
resets button font, colour, background and padding at a specificity a bare .st-x
loses to, so an unscoped rule silently does not apply to a button.

Escape clears the selection and only leaves on a second press. In a dialog
Escape means go away; in an editor that reflex would cost you the page.

* feat(journey): build, edit and arrange the book in Studio

The auto mode turns a journey into a book once and then gets out of the way: what
it produces is an ordinary document, every element as editable as one placed by
hand. Templates work the same way — applying one pours the spread's existing
photos and text into the new arrangement and parks whatever will not fit, so
trying a text-only layout does not cost you the pictures.

Moving and resizing are hand-rolled on pointer events. The drag-and-drop
libraries in this space move items between lists and have no notion of a free
frame, a handle or a magnetic edge; HTML5 drag hands you a ghost image you cannot
control and no position until the drop. It is used for exactly the one shape it
suits — dragging a photo out of the content panel onto a frame.

Selection chrome carries two rings, dark inside light. A single line vanishes
against a photograph of its own value, and over a picture that is bright on one
side and dark on the other no measurement of the background can be right.

The sheet renders in DOM rather than on a canvas so the same component tree can
be what the print renderer runs — one renderer, nothing to drift against. Edit
affordances live in a layer above it and can never reach the page.

* i18n(journey): studio strings across all 23 locales

The empty-frame prompt breaks per language rather than copying the English line
break onto everything — forced onto Japanese or Arabic it strands a fragment on
the second line.

* refactor(journey): keep SpreadView to components only

FONT_STACKS and photoSrc move to their own module: a file that exports both
components and constants breaks Fast Refresh, and this is a file you edit
constantly while designing a page. Types come from the journey store instead of
any, and the count beside the content tabs is a badge rather than competing with
the label for the same weight.

* feat(journey): more shapes, decoration and a content search in Studio

Six shapes now instead of three — rounded rectangle, triangle and an outline-only
box join the rest, plus a quotation mark and an outlined circle under decoration.
A triangle is a clip path rather than the old CSS border trick: that hack cannot
take a fill gradient or a stroke and its size does not follow the frame the way
everything else does. A stroke sits inside the frame, or the drawn shape would be
wider than the box the editor snapped and the print would not match it.

The content panel gets a search over photos and entries. A photo matches on its
own caption and on the entry it hangs from — most photos carry no words at all,
so matching captions alone would look broken on exactly the journeys that need
it. Plain filtering over what is already in memory; a journey holds tens or
hundreds of items, not thousands.

Dates in the panel and in the book now follow the app's language through the
house formatDate rather than the browser's locale, and the entry's date and place
are two badges instead of one dot-joined string the eye has to parse.

* @
feat(journey): a shape library, picture frames and photo grids for Studio

Studio had six shapes, two frames and no grids. This is the library that
makes it usable for a book rather than for a diagram.

Shapes are SVG paths now, not clip-paths. That was forced rather than
chosen: a clipped div cannot take a stroke — the clip cuts the border in
half lengthwise — and a heart is not a box with a radius. Rectangles and
ellipses keep their old div rendering, since a box genuinely is better as
a box and every document written so far contains those two.

Two decisions in shapes.ts are worth stating.

The path *points* are scaled, not the coordinate system. Fitting a path
with preserveAspectRatio="none" scales the stroke with it, so a 0.5mm
outline on a frame twice as wide as it is tall comes out 0.7mm on the
sides and 0.35mm top and bottom. Printed next to a straight rule at
300dpi, you see it. Hence the deliberately boring path subset: absolute
M/L/C/Q/Z, numbers alternating x and y, no arcs.

And every shape fills its 0..100 box exactly, enforced by normalise() at
module load rather than asked for in a comment. The editor draws its
selection outline, its resize handles and every snap target from the
element frame, so a shape occupying 86% of its own frame cannot be
aligned against anything you can see. It costs a regularity nobody can
spot — a hexagon in a square frame still reads as a hexagon.

Frames are the same list: a picture frame is a shape with a photograph
inside it, and keeping two would mean a heart that exists as one but not
the other. Photos gain a mask and a decoration, kept independent, plus
the Polaroid, film and tape treatments that are not expressible as either.

Grids take their gutter out of the cells rather than adding it between
them, so a grid asked to fill the safe area fills it instead of
overhanging by one gutter.
@

* @
feat(journey): derive what a journey adds up to

GET /api/journeys/:id/stats — distance, days, steps, photos, places, the
furthest point, the countries in visit order, and the route itself. Read
only and derived: nothing here is stored, it is what the trips added to
the journey come to.

The route comes from the journey entries where they carry coordinates,
and falls back to the places on the linked trips where they do not —
which is the common case for a journey assembled from trips nobody has
written up yet. The two are never mixed: an entry written about a place
that is also on the trip would be two stops at one location, and the
distance would count that leg twice.

Countries read place_regions first, which Atlas already fills, and fall
through to getCountryFromCoords for the rest. That import is a plain
function from a module built without DI or Nest edges, so it costs the
journey domain no coupling to Atlas.

Distance is great-circle metres, not driving distance: there is no
routing engine here, consecutive stops are not necessarily joined by a
road, and a book that prints a road distance it never measured is worse
than one that says "as the crow flies". Metres because kilometres and
miles are a display choice.

A hotel across three nights is one place with three day assignments, so
the place query aggregates back down to one row at its earliest day —
otherwise the route would visit the hotel three times.

Two test fixes came out of writing this. journey.controller.test.ts still
asserted an absolute path from res.sendFile, which the photo fix in this
branch had already changed to the basename-plus-root pair; and
linkTripToJourney in the factories wrote to linked_at, a column that does
not exist — it had no callers, so it had never once run.
@

* @
feat(journey): travel elements — the route, the figures, the countries

A photo book of a trip is not an album: the trip carries facts, and those
facts make pages photographs cannot. Four new elements — a vector route
map, a figures panel, a country page and a set of small marks — plus the
mood, weather and pros/cons an entry already records.

Every one of them carries its own values, resolved when the element is
placed and written into the document. The print renderer is headless
Chromium loading a page; one that needs an authenticated fetch to know
what it says will print blank the day a token expires. It also makes a
book a record of a trip as it was, rather than something that quietly
rewrites last year distance when a stop is added. travelRefresh compares
what is stored against the journey and offers an update, so bringing an
element up to date is a choice.

The map is vector, from the coordinates, over silhouettes simplified
offline from the admin-0 boundaries the server already bundles for Atlas
— no licence question, nothing to fetch at render time, sharp at any
print size. It paints no background: a map is geography sitting on the
paper, not a window cut through it, so it can be laid over a photograph.

Flags are drawn rather than typed. A regional-indicator pair is the
obvious answer and works everywhere except Chrome on Windows, which has
no flag glyphs and renders the two letters instead — "DE" in a printed
book is not a flag. Thirty-three flags are exact constructions; anything
with an emblem or unequal bands is deliberately absent and falls back to
the country outline, because a wrong flag is worse than no flag.

Panel tiles render the real element small, through the page own
renderer. These differ from each other by what they say rather than by
their shape: four map styles are four maps, and no icon can tell you
which is which.
@

* @
feat(journey): manage spreads from the pages rail

Add, insert between two spreads, duplicate, reorder and delete. None of
it existed — a book was whatever the auto layout produced.

The cover and the back cover are fixed points: a book has exactly one of
each, they are single pages, and no sequence of clicks should be able to
produce a book with two covers or none. Every operation asks one
predicate whether a spread is inner, rather than each caller testing role
for itself, and a spread inserted after the back cover lands before it.

Duplicating re-ids every element it copies. Two elements sharing an id
would make selection ambiguous and every lookup that follows it wrong,
delete included.

Also: a locked element can be selected again. Locking used to switch its
hit target off entirely, which was a dead end — it could not be picked,
so the inspector never showed it, so the unlock button was unreachable
and the only way back was undo. Locked now means what it says: it cannot
be moved, and its resize handles are hidden so the editor does not offer
a drag it will refuse.
@

* @
feat(journey): let the auto layout use the travel elements

The book now opens on a summary spread — the route drawn on the left, the
figures on the right — and, for a journey that crossed a border, a country
page after it. Both are omitted rather than left empty when there is
nothing to draw: a spread holding a blank frame over the words "0 KM" is
worse than a shorter book, and every page in a printed one costs money.

Only the figures the journey actually has make it onto the page. A zero
is not a fact worth a tile, it is a hole in a composition.

Each entry gains a short accent rule above its heading — one drawn
element, and the cheapest thing here that makes a spread read as designed
rather than as text placed on a page. An entry with no photographs, which
is the one spread with a blank left page, sets its date as a numeral
there: a gap in the book becomes a pause in it.

The back cover closed with `${places} Orte` — German, hardcoded, in a
book that follows the app language everywhere else. It is a figures
element now, so it takes its labels from the same translations as the
rest of Studio.

The layout waits for the figures rather than racing them. Laying out
first would give a journey a summary spread or not depending on which
promise resolved first, and Studio already shows its loading state until
the document exists, so the wait costs nothing.
@

* @
feat(journey): the Studio wordmark replaces the book-name field

The field asked for a second name for something already named after the
journey — a box whose only real use was being left alone. The bar says
what this is instead: the lockup, inline so it takes the theme ink
through currentColor rather than needing a light-on-dark copy, and a beta
mark beside it.

Along with the strings for everything in this branch, across all 23
locales, and the two keys the name field used, removed rather than left
as orphans nobody can tell from live ones.

journey.studio.frames was "Picture frames" — too long for a tab sharing a
row with two others, and untranslated in 21 of the 23 locales, which
showed the English through. It is one word now, in every language.
@

* @
feat(journey): a free trim size, page numbers, and auto layout as a button

Three from the list the PR left open.

**Custom trim.** The five presets are what vendors actually bind, which
covers almost everyone — "almost" being the problem. A local printer with
a 24x18 press, or a book meant for a particular shelf, could not be made
here at all. Two fields under the format list, and typing in them *is*
picking custom: a book that is 210 by 240 is not square-21 with a typo,
so the preset follows rather than needing to be chosen first. Switching
to custom keeps the page it is on — it adjusts the format you have.

**Page numbers.** Set on the document and drawn by the renderer, which is
the only place that knows what number a page is. Stamping them with
pdf-lib afterwards would print numbers the editor never showed, and a book
you cannot proofread on screen is not much of an editor. They are also the
one thing on a page that is not an element: an element holding "14" would
still say 14 after its spread was moved. `startAt` exists because the
first inner spread is rarely page 1 once bound, and outer/inner is a real
distinction on a spread — getting it backwards puts both numbers in the
fold.

The controls live in the inspector empty state, which was one line of
hint text. With nothing selected, that panel is where the book own
settings belong.

**Auto layout as a button.** It ran once, on open, which made it the one
thing in Studio you could not ask for; a page that came out wrong had to
be rebuilt by hand. Now it does one spread or the whole book, both as
ordinary commits — the first thing anyone does with a button like this is
press it to see, and the second is want their page back. A single spread
keeps its id so the rail does not scroll, and refuses on anything not
built from an entry rather than replacing it with a blank.
@

* @
feat(journey): cover layouts, and five more for the spreads

The cover had none. The panel said covers are designed by hand, which was
true and unhelpful — it is the page people care most about and the one
they had no starting point for.

A cover is a different problem from a spread and gets its own set rather
than a filter over the existing one: it is a single page, the type is the
subject rather than a caption to a photograph, and a spread layout applied
to it lands half its frames past the edge. Five of them, from full bleed
to type-only, and the panel serves the right set for whichever spread is
open. The cards scale as one page there, and drop the fold line, since
there is no fold.

Five more spread layouts as well — a picture with a narrow column of
story, nine up, a mosaic around one large frame, two pictures with the
words between them, and four staggered so a row of squares stops reading
as a contact sheet.

Applying a layout also stopped mangling a single text. The old rule took
the longest text as the story and looked for a heading among the rest,
which reads a three-text spread correctly and gets a one-text cover
exactly wrong: the 30pt title *is* the longest text, so it was the story,
so a cover layout with a heading slot and no body slot had nowhere to put
it and parked the title. Size decides the heading now; length only sorts
what is left.
@

* @
feat(journey): seven self-hosted book faces, and a picker that shows them

The serif slot was Georgia. Georgia is a system font — on most desktops,
on no Linux container — so the print renderer would have substituted
something else and a book proofread in one typeface would have printed in
another. The existing Journey PDF export has exactly this bug today,
asking for Inter while only Poppins is bundled.

Every family is self-hosted through @fontsource now, all OFL, all loaded
with Studio own chunk so a face cannot arrive after the text has been
laid out and reflow the line endings someone was setting. Two sans, three
serifs from bookish to high-contrast, two display faces for a cover.
Every stack ends in a generic rather than in another named font nobody
shipped.

The three original ids are kept by name, so documents written so far
still parse — they point at bundled faces rather than at whatever the
machine happened to have.

The picker sets each family in itself. A row of chips reading
"Poppins / Lora / Bebas" in the interface font tells you their names and
nothing about the only thing anyone is choosing between.

Weights a family does not ship are disabled rather than offered: asking
for bold on a single-weight display face gets a synthesised one, which
prints as a smeared regular. Picking such a family moves the weight to
the nearest it really has.
@

* @
feat(journey): real map imagery, from the providers TREK already has

The map element drew country outlines and nothing else. That is still the
default and still the right one — nothing fetched, nothing licensed,
sharp at any print size. But an outline of Iceland cannot show which road
you took, and some books want that.

So two more sources, both reading the tile template and Mapbox token an
admin already configured for the planner and Atlas. A second place to
paste a token is a second place for it to be wrong, and a source with
nothing behind it is not offered at all — showing "Mapbox" to someone
without a token produces a grey rectangle and no explanation anywhere.

Both costs are stated rather than hidden: tiles are fetched when the page
renders, and they are raster, cut for one zoom, so printing much larger
than they were fetched goes soft.

Attribution is neither optional nor a setting. The licence requires
credit and a book is a published work — nobody can add a footnote after
it is bound. The credit is matched on the host, so an instance running its
own OSM mirror still credits OpenStreetMap, and an unrecognised source
gets none rather than an invented one. It is written into the document
when the element is placed, so the renderer cannot lose it.

The route is projected onto the tile grid when there is one. The vector
map fits itself to country outlines, and a line placed by that fit sits
next to the road rather than on it — the sort of error that only shows up
once it is printed. Outlines are dropped under imagery, where they would
be a second coastline over the one in the picture.
@

* @
fix(journey): make the Studio type-check again

The map element used tileView and projectOntoTiles without importing them,
useMapSources declared a stricter frame type than any real frame satisfies,
and the mood icon read a colour through an `in` check that types as unknown.
The page fixtures in the studio tests predate page numbers, so they build a
BookPageSetup that the contract no longer accepts — now parsed from the
schema, which cannot drift from it.
@

* @
feat(journey): the book is saved

Studio laid a book out on every open and kept it in the tab. Close the tab
and the afternoon went with it, and the auto layout was not a starting point
but the only point.

The document now lives in journey_books, one row per journey, and autosaves:
quiet for a moment after the last change, or at the latest fifteen seconds
in, so a drag across a spread is one request rather than four hundred. A
stored book wins over a fresh layout on open, and closing the editor writes
whatever is still queued.

Everyone on a journey can edit its book, so the write carries the version it
was made against and the update matches on it in a single statement. A save
that has been overtaken is refused with the current record in the body,
which turns "your work vanished" into a choice between two versions. The
server broadcasts journey:book:saved to the other editors — the version, not
the document — and a client with nothing outstanding takes the new one. A
client with unsaved edits deliberately takes nothing and conflicts on its
next save instead.
@

* @
feat(journey): export the book

The one thing Studio could not do. The button was there and disabled.

A book now goes out as sheets: every inner spread cut down the middle into
leaves in reading order, bleed carried out past the trim on all four sides,
crop marks outside that saying where to cut. The cut is a window rather than
a re-layout, so a photograph crossing the gutter still meets itself once both
halves are printed. Spreads stay whole in the other mode, which is what you
send someone to read.

The browser makes the PDF, from the editor components at 1:1. Considered and
rejected: headless Chromium on the server, and a PDF library drawing the
pages a second time. TREK is self-hosted, and neither Chromium in the image
nor a second renderer to proofread against is worth carrying when the browser
already produces exactly what the editor shows — it is also how the other
exports here work. What the browser will not write is PDF metadata and the
TrimBox entries; the marks carry that instead, as they have since long before
the format existed.

Two things had to give. `SpreadView` clipped at the trim, which is right on
screen and leaves a white hairline down every edge of a bound book, so print
mode lets the page run out and the sheet does the clipping. And the print
document is its own document: it carries the app stylesheets across so the
seven bundled faces are the ones that print, and waits for images and fonts
before it offers to save.
@

* @
fix(journey): make the Studio popovers cover what they cover

The auto-layout menu sat on the glass tokens, which carry 42-58% opacity when
the user has transparency on. That reads as depth over the workbench, where
the rails and the bar use it, and as a mess over the inspector, where this
menu opens: two layers of text through each other, neither readable.

The export dialog was worse — it named --panel and --card, which are not
tokens here, so its background resolved to nothing at all and the whole thing
was see-through.

Both are opaque now. The other glass surfaces are left alone: they float over
the workbench, and that is what the material is for.

The print preview also fits the pane. A spread sheet is over 400 mm across,
so at 1:1 the book ran off the side and had to be read by scrolling sideways.
Screen only — the print stays at full size.
@

* fix(journey): a cover keeps its own size, and a trim size can be typed

Three things the export and the page setup got wrong.

Spread mode made every sheet as wide as the widest so that one @page rule
would cover them all. That put a cover — one page — in the middle of a
spread-sized sheet with white either side, which is not a cover. Each sheet
is now sized to what is on it, and the print CSS carries a named page rule
for the single-page ones.

The trim fields clamped on every keystroke, so typing 500 meant the field saw
5, clamped it to the minimum and wrote that back under the cursor: the number
could not be typed at all, only nudged with the arrows. The field now keeps
what is being typed and tells the document about values it can use. The
arrows are unchanged, and leaving the field still clamps — someone who typed
5 and clicked away meant the smallest book.

And the autosave could loop. The save effect depended on the whole store
object, which is new on every render, so after a conflict — where the local
document is by definition not the saved one — it was a save attempt per
render: a 409, a re-render, another attempt. It depends on the callbacks now,
and a conflict holds the autosave until somebody answers it.

* feat(journey): page numbers pick their own colour

A book has dark pages and light ones, often on the same spread, so one folio
colour is wrong on half of them: the warm grey that reads on paper vanishes
into a full-bleed night photograph, and white vanishes the moment the page is
white. The way out used to be setting it per spread, which the document has
no place for.

The renderer asks what is under the number instead. It knows every element
and where it sits, so it walks them from the top down and takes the first one
that covers the spot: a shape's fill is arithmetic, a photograph gets white
with a hairline of shadow — unknowable without decoding it, and dark more
often than not once printed — and bare page takes the page colour.

A heuristic, deliberately: the alternative is sampling pixels from an image
that may not have loaded, in a renderer that also has to run for print.
Anyone it gets wrong can still pick a colour, and picking one turns it off.

* feat(journey): see who else is in the book, and where they are

Two people editing the same document without seeing each other is how you get
two people editing the same spread. So: a badge in the bar saying who has the
book open, and an arrow with a name on it for each of them.

The coordinates are millimetres on the spread, not pixels on a screen — the
two of you are at different zoom levels on different monitors, and a pixel
means nothing to the other one. They carry a spread index too, so a pointer
on page 40 is not drawn on page 2.

Ephemeral throughout. Nothing is written down, the room is the authorisation
for the pointer path, and a socket that dies takes its arrow with it rather
than leaving it on everyone else's page. Presence is keyed by socket because
two tabs are two pointers; the badge folds them back into one person, because
two tabs are not two people.

The rate limit needed room. Pointers move about ten times a second, three
times what the per-socket limit allows, and that limit is what stops a client
flooding the process — so it is now two: an outer ceiling every frame counts
against before parsing, and the original tight limit on everything a pointer
is not. Malformed JSON still counts against the tight one, which is what the
guard was for.

Throttled to ten frames a second going out and interpolated coming in: sixty
would be five times the traffic for something the eye cannot use, and drawing
ten as they land reads as a stutter rather than a hand.

* fix(journey): keep the zoom bar on screen at every zoom level

Zoomed far enough in, the bar slid off the bottom of the window — and it is
the only way back out, so the editor was stuck at 300%.

The workbench sat in a grid track sized `auto`, which grows to whatever is in
it; `overflow: auto` on the child does not stop that, because a grid item's
minimum size is its content unless told otherwise. So at 300% the box grew to
2400px inside a 660px window and took its absolutely positioned bottom edge
with it. The track is bound now, and the workbench scrolls instead of growing.

The bar also has a z-index, for the same reason it has to stay in view: it
must never be the thing something else covers.

* feat(journey): Studio replaces the old book export

The journey PDF and Studio were two ways to print the same book that disagreed
about what it looks like — and only one of them can be edited. The old export
goes: the two buttons on the desktop already sat next to the Studio one, and
the renderer they called is deleted rather than left as a second answer.

Nothing takes its place on the phone. The book is a Studio document now and
Studio is a desktop editor; a phone-sized PDF of something laid out for print
was the old export's answer to a question nobody was asking.

* feat(journey): lay the book out the way a travel book is laid out

The auto layout built every spread the same way: a picture on the left, words
on the right, and nothing else. It came out looking like a template because it
was one — and on a journey with little in it, like a stack of blank paper.

Five layouts now, chosen by what an entry has and alternating as the book goes
on. A place page for a stop nobody wrote about, with the country pale behind
the name and the coordinates under it. A story page with a small map of the
stop and the picture bleeding off three edges. A panorama across the fold. A
gallery with the hero on one page and a ruled strip on the other. And every
fourth picture spread goes full bleed with the words set over it, because a
book of loud pages has no loud pages.

The marks are what actually make these pages: a flag beside the country, a
date numeral, a chip saying which day this is, a rule along the foot with a
dot showing how far in you are. Each is trivial and together they are the
difference between a page that was designed and one that was filled in. All of
them are elements the panels can make, so what comes out can be taken apart.

The summary spread gained a heading and the furthest point drawn as two dots
and a line — that figure means nothing until it is a distance between two
names. The countries page went dark, because the printed one is: pale outlines
on near-black is the single most recognisable page in these books.

Two things the old layout got wrong are fixed on the way. Entries with nothing
on them share one page instead of taking a spread each, in the order they
happened rather than gathered at the end. And an entry whose photographs have
not arrived gets the frames they will go in, empty, rather than blank paper —
the layout is finished and the pictures are the only thing missing, which is
the right way round.

* feat(journey): build the spreads out of areas, and set the elements in ink

Two things, both about what a page is made of.

The layouts were photographs with captions. A printed travel book is built out
of areas: a page laid solid in ink with pale type on it, a band down the outer
edge, a picture that overruns the panel it sits against, a box ruled around a
block of text. Two more layouts do that now — one with a whole page in ink and
a second photograph crossing the fold onto it, one with an edge band and the
words in a ruled box, pictures at two sizes and one overlapping the hero. Six
layouts on a rotation, so the book stops reading as a template.

None of it needed a new feature. The document paints in array order, so a panel
pushed first is a panel everything else lands on; that is all layering is, and
it needed using rather than adding.

And the travel elements are ink by default rather than the app's orange. A book
is printed and a printed page is ink on paper; a colour that arrives without
being chosen turns every element into the same accent whether the book wants
one or not. Picking one is a click. The dark map is the exception — the route
goes pale there, because ink on a near-black country is a line nobody can see,
and only the default is overridden: a colour somebody chose stays theirs.

* fix(journey): three things the editor got wrong

An empty photo frame rendered on its own, ignoring frameStyle entirely — so a
Polaroid placeholder was a plain dashed rectangle until a photograph landed in
it and the chin appeared from nowhere. The two branches are folded together
now: the surround is drawn once and only what goes inside it differs, so a
frame shows what it will look like before it is filled.

Adding a page to a book with no inner pages yet put it in front of the cover,
where it could not be moved back from either — a move only ever swaps with
another inner spread, and the cover is not one. An empty book reports its last
inner spread as -1, and -1 + 1 is 0. The insert is bounded at both ends now.

And snapping only knew the outer safe lines. A spread draws that dashed rule
inset from each of its four vertical edges, so dragging inwards caught the fold
and nothing else — the line you could see just before it was not a target. All
four are, along with the centre of each page and the quarters within it: on a
spread the halfway mark is the fold, so a picture centred on its own page had
nothing to find.

* feat(journey): take the auto layout's spreads from templates drawn by hand

The layouts written as code are what a function can reason its way to: a
picture here, words there, margins that add up. What they are not is designed —
a panel bled off one edge, a shape running off a corner, a strip of pictures at
two sizes with the coordinates set beneath them. Those decisions come from
looking at a page, and the editor is where looking at a page happens.

So a spread built in Studio can now be the layout. `extract-templates.cjs`
reads a journey's book and writes its spreads out as data, with every
measurement divided by the page it was drawn on — a template built on a 210mm
square lays out on an A4 landscape book without being redrawn. `applyTemplate`
does the other half: it fills what the entry can answer for and copies
everything else as drawn.

That division is the whole contract. Text carrying a binding takes the title or
the story; empty frames take the photographs in order; the day, coordinate,
date and country marks take what the stop knows. Panels, rules and decorative
shapes are the design and are left exactly as they were put.

A template is one *for an entry* when it uses one — which has to come out of
the drawing rather than a label somebody remembers to set. A summary spread of
figures and country outlines reads perfectly well on its own, and dropping a
day's photographs into it would be nonsense.

Two templates ship with this, drawn by the person who will use them. The
built-in layouts stay as the fallback for entries no template suits.

* feat(journey): turn elements freely, and pass a spread on as a file

Rotation by the corner, the way it works everywhere else: a handle sits
diagonally outside each of the four corners of the selection, and dragging one
turns the element by the change in angle rather than to the angle under the
pointer, so it can be picked up anywhere without jumping. Held to 15 degree
steps unless shift is down. Four handles rather than one because a single
handle off the top right sits under the inspector for anything in the right
page's upper half.

And a spread can now leave the book it was made in. The zoom bar downloads the
one on screen as a file; Import, beside Add spread in the pages rail, brings one
back. Measurements travel as fractions of the page, so a spread drawn on a 210mm
square lays out correctly in an A4 landscape book, the same way the auto
layout's templates do.

What deliberately does not travel:

  - Photographs. The file carries the design, and the frames arrive empty.
  - The map source. A static map freezes the URL it was rendered from, and on a
    Mapbox instance that URL has the access token in it, so a file meant to be
    posted in a forum would carry a credential. Coming the other way, a tile
    template from a stranger is a URL every reader's browser would fetch tile by
    tile: an IP address handed to whoever wrote the file.

An imported file is treated as what it is, somebody else's JSON. It is read in
the tab that chose it and never uploaded, parsed and never evaluated, and every
element goes through the document contract, which drops unknown fields and
range-checks the rest. Colours in particular are #rrggbb or nothing. The size of
the file and the number of elements are both capped, because the damage a
hostile file can do here is not code execution, it is handing the editor more
than the server will accept and leaving a document that will not save.

Also fixes the rotation handle being drawn but not grabbable: the chrome layer
and the selection box both pass pointers through, and a handle has to opt back
in with pointer-events.

* docs(wiki): add two Studio screenshots for the addon page

The editor with a stats spread open, and the frames panel. Both are what the
Journey addon page will want once Studio ships, and both are wanted now to show
the feature off while it is still in review.

* test(realtime): cover the book room's refusals and its dead sockets

The realtime coverage ratchet caught what the book room work left behind: the
three handlers each have an early return for a message that is missing its
journey id or comes from a socket with no user, and none of them was exercised.
Every one of those is a silent failure by construction. A handler that did
something with a missing journey id would not throw, it would put a socket
somewhere it does not belong, and nobody would notice until two strangers were
looking at the same page.

The other half is sockets that are in a room but cannot be used: a connection
that dropped before its close handler ran, or one put in a room without ever
being registered. The peer list has to walk past both rather than showing a
ghost, and the broadcast has to skip them rather than throwing on a path that
runs ten times a second per editor.

Both files are back to full branch coverage, so the domain's pins hold as they
are rather than being lowered to fit.

* feat(journey): make the route map a map of the journey

Four things were wrong with it, and they compounded.

**It showed the wrong place.** The view was fitted to the country outlines as
well as the stops, so a trip that stayed inside Berlin was drawn as the whole of
Germany with two dots in the middle of it. It fits what the journey did now, and
the outlines run off the edge of the frame the way a coastline does on any real
map. Fitting to the country is still there as a deliberate choice, for the page
every travel book has, and how much air to leave around either is a setting
rather than a constant.

**It drew a line nobody travelled.** Stops joined in order is a claim about the
journey that is almost always false: it crosses mountains, water and borders the
trip went around. TREK already stores routed geometry per place, from the
planner and from imported GPX, so the map now follows the road where the trip
knows it and falls back to joining the stops where it does not. Segments stay
separate, so a trip with a flight in the middle does not grow a road across the
sea. The geometry is thinned when it is frozen into the element: a printed line
gains nothing from a point every fifteen metres, and a book carrying every GPS
fix of a three-week trip is a document nobody can save.

**The tiled and static sources drew nothing at all.** The tile grid was
computed, the route was projected onto it, and then no picture was ever
rendered — the map came out as a frame with a line across it. Three separate
faults behind that: the imagery layer did not exist, `{s}` was left unsubstituted
so every tile URL named a host that does not resolve, and the grid was cut from
the tiles the route happened to sit on rather than from what the frame shows, so
it stopped short of the edge. The frame decides the grid now, and it covers by
construction. Tiles overlap by a tenth of a millimetre, which closes the hairline
of white paper that rounding left between them.

Mapbox needed one more thing: `mapbox/standard` is a 3D GL style and the Static
Images API refuses it outright, so an instance configured for the planner's map
was configured with a style this cannot use. It falls back to the classic
equivalent, and a URL frozen into a document before that existed is repaired on
the way out rather than left broken for good.

**A map was always a rectangle.** It can be cut to the outline of the countries
now, so the picture *is* the country: no frame, no box, the page showing through
around the coastline. That is the version worth laying next to a photograph, and
the one thing a rectangle can never be. The tile grid and the vector fit are
projected from one extent so the picture and the shape it is cut to line up —
two fits meant the country was full of the wrong towns.

Also here: the credit line comes off the map itself. It is still resolved and
still stored, for the colophon, which is where a book has always credited its
sources.

* feat(journey): split the country list, and trim the map cards

The country list and the country grid were compositions somebody else made:
they decided the order, the spacing and the type size, and the only way to
change any of it was to not use them. They are gone, and what is left is the
same entry — the name over its silhouette, set the same way — as an element
placed one country at a time. Three down a page is the list, with the spacing
yours; one at 80mm is a chapter opener, which the list could never be. The name
is editable, so numbering them "1. Germany", "2. Netherlands" is typing rather
than a feature. The compact mark, outline beside the name at the size of a line
of type, stays for the places that want a mark rather than a heading.

The map section offers the four vector styles and nothing else. Tiles, Mapbox
and the cut-out are one click away in the inspector, where the rest of the map's
settings already live, and four cards that are the same map four times were
crowding out everything under them.

A map is fitted to the country by default now, which is the picture a travel
book usually wants. Fitting to the stops is still there and still matters the
moment the trip is smaller than the country it happened in.

Also: no navigation fallback from the dev service worker. In production it is
right — offline, a deep link has to be answered by the cached shell. In
development it answers a reload with the index.html it cached earlier, whose
script tags point at module URLs from before the last restart, so the browser
re-runs the previous version of the app while the editor shows the new one.
Every explanation for that is wrong until someone thinks of the service worker.

* fix(journey): drop what the trimmed map section left behind

Three bindings that only fed the cards removed in the commit before: the map
sources hook, the country codes the cut-out card needed, and the frame the
preview sized itself from. Lint had them as warnings rather than errors, so
they would have ridden along quietly.
This commit is contained in:
Maurice
2026-08-19 23:36:42 +02:00
committed by GitHub
parent 7164f4fb11
commit 736599a89b
148 changed files with 31119 additions and 855 deletions
+5
View File
@@ -29,8 +29,13 @@
"format:check": "prettier --check \"src/**/*.tsx\" \"src/**/*.css\""
},
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
+278
View File
@@ -0,0 +1,278 @@
#!/usr/bin/env node
/**
* Country silhouettes for TREK Studio, built from the boundaries the server
* already bundles for Atlas.
*
* node scripts/build-country-shapes.mjs
*
* Writes `src/components/Studio/countryShapes.ts`. Run it when
* `server/assets/atlas/admin0.geojson.gz` changes; the output is committed, so
* a normal build never runs this.
*
* ── Why a second, tiny copy of the boundaries ─────────────────────────────
*
* Atlas's admin-0 file is 4.3MB gzipped and ~30MB parsed, sized for a slippy
* map you can zoom into. A book page draws a country at 20mm across, where the
* ten thousand points describing a fjord land inside one printer dot. Shipping
* the map data to draw a stamp would cost more than the entire Studio bundle.
*
* So: project, drop the islands nobody can see at that size, simplify hard, and
* normalise each country into its own 0..100 box. What comes out is a few
* hundred bytes per country and prints identically, because at 20mm it *is*
* identical.
*
* ── The three things that are easy to get wrong ───────────────────────────
*
* 1. **Project before simplifying.** Simplifying in degrees and projecting
* afterwards removes the wrong points: a degree of longitude is 111km at the
* equator and 30km at Reykjavík, so a fixed tolerance eats Iceland's coast
* while leaving Kenya's untouched.
* 2. **Countries that cross the antimeridian.** Russia and Fiji have parts at
* +179 and -179. Taken literally their bounding box is the whole world and
* the silhouette is a horizontal smear. Rings that span more than half the
* globe get their negative longitudes lifted by 360 so the country is
* contiguous again.
* 3. **Islands.** Keeping every ring turns Norway into a dust cloud and Greece
* into static. Rings below a fraction of the largest one are dropped — the
* mainland is what makes a country recognisable at a glance.
*/
import fs from 'node:fs'
import path from 'node:path'
import url from 'node:url'
import zlib from 'node:zlib'
const HERE = path.dirname(url.fileURLToPath(import.meta.url))
const SRC = path.join(HERE, '..', '..', 'server', 'assets', 'atlas', 'admin0.geojson.gz')
const OUT = path.join(HERE, '..', 'src', 'components', 'Studio', 'countryShapes.ts')
/** Rings smaller than this share of the biggest one are not drawn. */
const ISLAND_CUTOFF = 0.012
/** Never draw more than this many rings for one country. */
const MAX_RINGS = 14
/** Point budget per country, across every ring it keeps. */
const MAX_POINTS = 190
/** Web Mercator, unscaled. y is flipped so it grows downwards like SVG's. */
function project([lng, lat]) {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return [lng, -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI]
}
function ringArea(ring) {
let a = 0
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
a += ring[j][0] * ring[i][1] - ring[i][0] * ring[j][1]
}
return Math.abs(a / 2)
}
/** Perpendicular distance from p to the segment ab, squared. */
function segDistSq(p, a, b) {
let x = a[0], y = a[1]
let dx = b[0] - x, dy = b[1] - y
if (dx !== 0 || dy !== 0) {
const t = ((p[0] - x) * dx + (p[1] - y) * dy) / (dx * dx + dy * dy)
if (t > 1) { x = b[0]; y = b[1] } else if (t > 0) { x += dx * t; y += dy * t }
}
dx = p[0] - x
dy = p[1] - y
return dx * dx + dy * dy
}
/** Douglas-Peucker, iterative so a long coastline cannot blow the stack. */
function simplify(points, tolerance) {
if (points.length < 3) return points
const tolSq = tolerance * tolerance
const keep = new Uint8Array(points.length)
keep[0] = 1
keep[points.length - 1] = 1
const stack = [[0, points.length - 1]]
while (stack.length) {
const [first, last] = stack.pop()
let maxSq = 0
let index = 0
for (let i = first + 1; i < last; i++) {
const d = segDistSq(points[i], points[first], points[last])
if (d > maxSq) { maxSq = d; index = i }
}
if (maxSq > tolSq) {
keep[index] = 1
stack.push([first, index], [index, last])
}
}
return points.filter((_, i) => keep[i])
}
/** Every outer ring of a Polygon or MultiPolygon, holes discarded. */
function outerRings(geometry) {
if (!geometry) return []
if (geometry.type === 'Polygon') return geometry.coordinates.slice(0, 1)
if (geometry.type === 'MultiPolygon') return geometry.coordinates.map(poly => poly[0])
return []
}
/**
* Lift a ring that straddles the antimeridian into a continuous longitude
* range. A ring whose points span more than half the globe is, in practice,
* always a wrap rather than a genuinely hemisphere-wide landmass.
*/
function unwrap(ring) {
let min = Infinity
let max = -Infinity
for (const [lng] of ring) { if (lng < min) min = lng; if (lng > max) max = lng }
if (max - min <= 180) return ring
return ring.map(([lng, lat]) => [lng < 0 ? lng + 360 : lng, lat])
}
function build(feature) {
const rings = outerRings(feature.geometry)
.map(unwrap)
.map(ring => ring.map(project))
.map(ring => ({ ring, area: ringArea(ring) }))
.sort((a, b) => b.area - a.area)
if (!rings.length) return null
const biggest = rings[0].area
const kept = rings
.filter(r => r.area >= biggest * ISLAND_CUTOFF)
.slice(0, MAX_RINGS)
.map(r => r.ring)
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
for (const ring of kept) {
for (const [x, y] of ring) {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
}
const spanX = maxX - minX
const spanY = maxY - minY
if (!(spanX > 0) || !(spanY > 0)) return null
// The longer side becomes 100 and the shorter one keeps its proportion, so a
// country never has to be squashed to fit the box it is drawn in.
const scale = 100 / Math.max(spanX, spanY)
const w = spanX * scale
const h = spanY * scale
const normalised = kept.map(ring =>
ring.map(([x, y]) => [(x - minX) * scale, (y - minY) * scale]))
// Tolerance climbs until the whole country fits its point budget. Starting
// from the diagonal keeps the loop short for a country of any size.
let tolerance = 0.14
let simplified = normalised.map(r => simplify(r, tolerance))
let guard = 0
while (simplified.reduce((n, r) => n + r.length, 0) > MAX_POINTS && guard++ < 40) {
tolerance *= 1.35
simplified = normalised.map(r => simplify(r, tolerance))
}
const d = simplified
.filter(ring => ring.length >= 3)
.map(ring => `M${ring.map(([x, y]) => `${round(x)} ${round(y)}`).join('L')}Z`)
.join('')
// The projected bounds, kept so the same path can be replayed in world
// coordinates. A country drawn on its own wants its own box; the same country
// on a route map has to sit where it actually is, next to its neighbours. One
// normalised path plus these four numbers serves both, where storing two sets
// of coordinates would be twice the bytes and one more thing to disagree.
return d ? { d, w: round(w), h: round(h), b: [minX, minY, maxX, maxY].map(v => Math.round(v * 100) / 100) } : null
}
const round = n => Math.round(n * 10) / 10
const raw = zlib.gunzipSync(fs.readFileSync(SRC))
const collection = JSON.parse(raw.toString('utf8'))
const shapes = new Map()
for (const feature of collection.features) {
const code = String(feature.properties?.ISO_A2 || '').toUpperCase()
if (code.length !== 2 || code === '-9') continue
const built = build(feature)
if (!built) continue
// A code appearing twice means two features for one country; keep the one
// with more outline, which is the mainland rather than a dependency.
const prev = shapes.get(code)
if (!prev || built.d.length > prev.d.length) shapes.set(code, built)
}
const codes = [...shapes.keys()].sort()
const body = codes
.map(code => {
const s = shapes.get(code)
return ` ${code}: { d: '${s.d}', w: ${s.w}, h: ${s.h}, b: [${s.b.join(', ')}] },`
})
.join('\n')
const file = `/**
* GENERATED — do not edit. Run \`node scripts/build-country-shapes.mjs\`.
*
* Country silhouettes, simplified from the admin-0 boundaries the server
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
* normalised so the longer side is 100 units; \`w\` and \`h\` give the shape's
* own proportions inside that box so it can be drawn without being squashed.
*
* ${codes.length} countries, built for print at postage-stamp size — islands
* below ${ISLAND_CUTOFF * 100}% of the mainland are dropped and each outline is
* simplified to at most ${MAX_POINTS} points.
*/
export interface CountryShape {
/** Path on a 0..100 box, outer rings only. */
d: string
/** The shape's own width and height inside that box. */
w: number
h: number
/** Projected bounds \`[minX, minY, maxX, maxY]\`, for drawing it in place. */
b: [number, number, number, number]
}
/**
* Web Mercator, matching the projection the paths were built with. Longitude
* passes through as degrees; latitude is flipped so y grows downwards like
* SVG's does.
*/
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
}
/**
* The same outline, placed in projected world coordinates.
*
* The stored path is normalised into its own box; multiplying by the box's span
* and shifting by its origin puts it back where the country actually is, which
* is what a map with more than one country on it needs.
*/
export function countryWorldPath(shape: CountryShape): string {
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
let axis = 0
return shape.d.replace(/-?\\d*\\.?\\d+/g, m => {
const isX = axis++ % 2 === 0
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
return String(Math.round(v * 1000) / 1000)
})
}
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
${body}
}
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
export function countryShape(code: string | null | undefined): CountryShape | null {
if (!code) return null
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
}
`
fs.writeFileSync(OUT, file, 'utf8')
const kb = Math.round(Buffer.byteLength(file) / 102.4) / 10
console.log(`[country-shapes] ${codes.length} countries, ${kb} kB → ${path.relative(process.cwd(), OUT)}`)
+7 -1
View File
@@ -48,6 +48,7 @@ const HelpPage = lazyWithRetry(() => import('./pages/HelpPage'))
const AtlasPage = lazyWithRetry(() => import('./pages/AtlasPage'))
const JourneyPage = lazyWithRetry(() => import('./pages/JourneyPage'))
const JourneyDetailPage = lazyWithRetry(() => import('./pages/JourneyDetailPage'))
const JourneyStudioPage = lazyWithRetry(() => import('./pages/JourneyStudioPage'))
const CollectionsPage = lazyWithRetry(() => import('./pages/CollectionsPage'))
const JourneyPublicPage = lazyWithRetry(() => import('./pages/JourneyPublicPage'))
const SharedTripPage = lazyWithRetry(() => import('./pages/SharedTripPage'))
@@ -513,7 +514,12 @@ export default function App() {
<ViewportRoute phone={MJourneyDetailScreen} desktop={JourneyDetailPage} />
</ProtectedRoute>
}
/>
>
{/* Studio is nested so the journey stays mounted underneath it and
shows through the panel's margin — it is opened on top of the
journey, not navigated away to. */}
<Route path="studio" element={<JourneyStudioPage />} />
</Route>
<Route
path="/collections"
element={
+15
View File
@@ -12,6 +12,7 @@ import {
type NotificationRespondRequest,
type SettingUpsertRequest, type SettingsBulkRequest,
type JourneyCreateRequest, type JourneyAddTripRequest, type JourneyTracksResponse,
type JourneyStats, type BookRecord, type BookSaveRequest,
type JourneyReorderEntriesRequest, type JourneyProviderPhotosRequest,
type JourneyShareLinkRequest,
type RegisterRequest, type LoginRequest, type ForgotPasswordRequest,
@@ -889,6 +890,20 @@ export const journeyApi = {
listEntries: (id: number) => apiClient.get(`/journeys/${id}/entries`).then(r => r.data),
// GPX tracks of the trips this journey's entries came from (#1260).
listTracks: (id: number): Promise<JourneyTracksResponse> => apiClient.get(`/journeys/${id}/tracks`).then(r => r.data),
// What the journey adds up to — distance, days, countries, the route (#1973).
// Read by TREK Studio, which freezes the answer into the book document.
stats: (id: number): Promise<JourneyStats> => apiClient.get(`/journeys/${id}/stats`).then(r => r.data),
// The Studio book (#1973). `book` is null for a journey that has none yet —
// Studio lays one out and the first save creates it.
getBook: (id: number): Promise<{ book: BookRecord | null }> =>
apiClient.get(`/journeys/${id}/book`).then(r => r.data),
// A 409 carries the current record in its body, so a conflict can be shown
// rather than only announced. See useBookStore.
saveBook: (id: number, body: BookSaveRequest): Promise<BookRecord> =>
apiClient.put(`/journeys/${id}/book`, body).then(r => r.data),
deleteBook: (id: number): Promise<void> =>
apiClient.delete(`/journeys/${id}/book`).then(r => r.data),
createEntry: (id: number, data: Record<string, unknown>) => apiClient.post(`/journeys/${id}/entries`, data).then(r => r.data),
updateEntry: (entryId: number, data: Record<string, unknown>) => apiClient.patch(`/journeys/entries/${entryId}`, data).then(r => r.data),
deleteEntry: (entryId: number) => apiClient.delete(`/journeys/entries/${entryId}`).then(r => r.data),
+49
View File
@@ -9,6 +9,15 @@ let reconnectDelay = 1000
const MAX_RECONNECT_DELAY = 30000
const listeners = new Set<WebSocketListener>()
const activeTrips = new Set<string>()
/**
* Studio books this tab has open, so presence survives a reconnect.
*
* Same reasoning as `activeTrips`: the server forgets the room when the socket
* dies, and without rejoining, everyone else keeps showing a pointer for
* somebody who is no longer there.
*/
const activeBooks = new Set<string>()
let shouldReconnect = false
let refetchCallback: RefetchCallback | null = null
let mySocketId: string | null = null
@@ -110,6 +119,11 @@ async function connectInternal(_isReconnect = false): Promise<void> {
socket.onopen = () => {
reconnectDelay = 1000
activeBooks.forEach(journeyId => {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId }))
}
})
if (activeTrips.size > 0) {
activeTrips.forEach(tripId => {
if (socket && socket.readyState === WebSocket.OPEN) {
@@ -166,6 +180,7 @@ export function disconnect(): void {
reconnectTimer = null
}
activeTrips.clear()
activeBooks.clear()
if (socket) {
socket.onclose = null
socket.close()
@@ -187,6 +202,40 @@ export function leaveTrip(tripId: number | string): void {
}
}
/**
* Open a Studio book — presence and pointers, for as long as the editor is up.
*/
export function joinBook(journeyId: number | string): void {
activeBooks.add(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:join', journeyId: String(journeyId) }))
}
}
export function leaveBook(journeyId: number | string): void {
activeBooks.delete(String(journeyId))
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(JSON.stringify({ type: 'book:leave', journeyId: String(journeyId) }))
}
}
/**
* Where this tab's pointer is, in millimetres on the spread.
*
* Dropped silently when the socket is not open. A pointer is worth nothing a
* moment later, so there is nothing to queue and nothing to retry — the next
* one is along in a tenth of a second.
*/
export function sendBookCursor(
journeyId: number | string,
spreadIndex: number,
x: number | null,
y: number | null,
): void {
if (!socket || socket.readyState !== WebSocket.OPEN) return
socket.send(JSON.stringify({ type: 'book:cursor', journeyId: String(journeyId), spreadIndex, x, y }))
}
export function addListener(fn: WebSocketListener): void {
listeners.add(fn)
}
+8
View File
@@ -57,6 +57,14 @@ export const HANDLED_OUTSIDE_TRIP_STORE = [
'journey:entry:deleted',
'journey:entries:reordered',
'journey:contributor:changed',
// Studio book — components/Studio/useBookStore (its own listener: a client
// with nothing outstanding takes the new version, one with unsaved edits
// deliberately does not and conflicts on its next save instead)
'journey:book:saved',
// Studio presence — components/Studio/useBookPresence (its own listener:
// who has the book open, and where their pointers are)
'journey:book:peers',
'journey:book:cursor',
// Booking import — BackgroundTasks/BackgroundTasksWidget ('import:' prefix listener)
'import:progress',
'import:done',
@@ -1,247 +0,0 @@
// FE-COMP-JOURNEYPDF-001 to FE-COMP-JOURNEYPDF-012
//
// JourneyBookPDF.tsx exports an async function
// `downloadJourneyBookPDF(journey, { t, locale })` that renders a PDF preview in
// an srcdoc iframe overlay (Safari-safe pattern). Tests verify the overlay DOM
// structure, the HTML content and that the book follows the app language.
import { describe, it, expect, vi, afterEach } from 'vitest';
// Mock `marked` so we don't need the real markdown parser
vi.mock('marked', () => ({
marked: {
parse: (str: string) => `<p>${str}</p>`,
},
}));
import { downloadJourneyBookPDF } from './JourneyBookPDF';
import type { JourneyDetail } from '../../store/journeyStore';
// ── Helpers ──────────────────────────────────────────────────────────────────
function buildJourney(overrides: Partial<JourneyDetail> = {}): JourneyDetail {
return {
id: 1,
user_id: 1,
title: 'Iceland Ring Road',
subtitle: 'Two weeks around the island',
status: 'active',
cover_image: null,
cover_gradient: null,
created_at: Date.now(),
updated_at: Date.now(),
entries: [
{
id: 10,
journey_id: 1,
author_id: 1,
type: 'entry',
title: 'Golden Circle',
story: 'An incredible day of geysers and waterfalls.',
entry_date: '2026-07-01',
entry_time: '09:00',
location_name: 'Thingvellir',
location_lat: 64.255,
location_lng: -21.13,
mood: 'excited',
weather: 'sunny',
tags: [],
pros_cons: { pros: ['Amazing views'], cons: ['Crowded'] },
visibility: 'private',
sort_order: 0,
created_at: Date.now(),
updated_at: Date.now(),
source_trip_id: null,
source_place_id: null,
source_trip_name: null,
photos: [
{
id: 100,
entry_id: 10,
provider: 'local',
file_path: 'journey/geyser.jpg',
thumbnail_path: null,
asset_id: null,
owner_id: null,
shared: 0,
caption: 'Strokkur erupting',
sort_order: 0,
created_at: Date.now(),
},
],
},
],
trips: [],
contributors: [],
stats: { entries: 1, photos: 1, cities: 1 },
...overrides,
} as unknown as JourneyDetail;
}
// ── Helpers to inspect the overlay ───────────────────────────────────────────
function getOverlay(): HTMLElement | null {
return document.getElementById('journey-pdf-overlay');
}
function getIframe(): HTMLIFrameElement | null {
return getOverlay()?.querySelector('iframe') ?? null;
}
// Echoes the key (plus any params) so the assertions read as "this string comes
// from i18n" instead of pinning English copy.
function t(key: string, params?: Record<string, string | number>): string {
if (!params) return key;
return `${key}(${Object.entries(params).map(([k, v]) => `${k}=${v}`).join(',')})`;
}
const longDate = (d: string, locale: string) =>
new Date(d + 'T00:00:00').toLocaleDateString(locale, {
weekday: 'long', month: 'long', day: 'numeric', year: 'numeric',
});
// ── Setup ────────────────────────────────────────────────────────────────────
afterEach(() => {
document.getElementById('journey-pdf-overlay')?.remove();
vi.restoreAllMocks();
});
// ── Tests ────────────────────────────────────────────────────────────────────
describe('downloadJourneyBookPDF', () => {
it('FE-COMP-JOURNEYPDF-001: appends overlay to document body', async () => {
await downloadJourneyBookPDF(buildJourney());
expect(getOverlay()).not.toBeNull();
expect(document.body.contains(getOverlay())).toBe(true);
});
it('FE-COMP-JOURNEYPDF-002: overlay contains an iframe with srcdoc HTML', async () => {
await downloadJourneyBookPDF(buildJourney());
const iframe = getIframe();
expect(iframe).not.toBeNull();
const html = iframe!.srcdoc;
expect(html).toContain('<!DOCTYPE html>');
expect(html).toContain('</html>');
});
it('FE-COMP-JOURNEYPDF-003: overlay has close and save buttons', async () => {
await downloadJourneyBookPDF(buildJourney());
const overlay = getOverlay()!;
expect(overlay.querySelector('#journey-pdf-close')).not.toBeNull();
expect(overlay.querySelector('#journey-pdf-save')).not.toBeNull();
});
it('FE-COMP-JOURNEYPDF-004: HTML contains the journey title', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road');
});
it('FE-COMP-JOURNEYPDF-005: HTML contains entry content', async () => {
await downloadJourneyBookPDF(buildJourney());
const html = getIframe()!.srcdoc;
expect(html).toContain('Golden Circle');
// Story text is rendered via markdown
expect(html).toContain('An incredible day of geysers and waterfalls.');
// Pros/cons verdict cards are included
expect(html).toContain('Amazing views');
expect(html).toContain('Crowded');
});
it('FE-COMP-JOURNEYPDF-006: handles empty entries gracefully', async () => {
const journey = buildJourney({ entries: [] });
await downloadJourneyBookPDF(journey);
expect(getOverlay()).not.toBeNull();
const html = getIframe()!.srcdoc;
expect(html).toContain('Iceland Ring Road');
// No entry pages, but cover and closing page are still present. Without a
// translator the chrome falls back to the raw keys instead of throwing.
expect(html).toContain('journey.pdf.journeyBook');
expect(html).toContain('journey.pdf.theEnd');
});
it('FE-COMP-JOURNEYPDF-007: sanitises HTML injected via an entry story and keeps the iframe script-free', async () => {
const journey = buildJourney();
journey.entries[0].story = 'Hello <script>alert(1)</script> <img src=x onerror="alert(2)"> world';
await downloadJourneyBookPDF(journey);
const iframe = getIframe()!;
const html = iframe.srcdoc;
// The script tag, image beacon and event handler are stripped from the story.
expect(html).not.toContain('<script');
expect(html).not.toContain('onerror');
expect(html).not.toContain('alert(2)');
// Benign prose survives.
expect(html).toContain('Hello');
expect(html).toContain('world');
});
it('FE-COMP-JOURNEYPDF-008: renders cover, day header and closing page through i18n', async () => {
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
const html = getIframe()!.srcdoc;
expect(html).toContain('<html lang="de">');
expect(html).toContain('journey.pdf.journeyBook');
expect(html).toContain('journey.stats.days');
expect(html).toContain('journey.stats.entries');
expect(html).toContain('journey.stats.photos');
expect(html).toContain('journey.pdf.madeWith');
expect(html).toContain('journey.pdf.theEnd');
// Day header is the parameterised key plus a date in the picked locale —
// the hardcoded 'en' formatting would not produce this string.
expect(html).toContain('journey.detail.day(number=1)');
expect(html).toContain(longDate('2026-07-01', 'de-DE'));
// Pros/cons headings too.
expect(html).toContain('journey.verdict.lovedIt');
expect(html).toContain('journey.verdict.couldBeBetter');
});
it('FE-COMP-JOURNEYPDF-009: translates the preview bar above the book', async () => {
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'de-DE' });
const overlay = getOverlay()!;
// cover + one entry page + closing page
expect(overlay.textContent).toContain('3 journey.pdf.pages');
expect(overlay.querySelector('#journey-pdf-save')!.textContent).toBe('journey.pdf.saveAsPdf');
expect(overlay.querySelector('#journey-pdf-close')!.textContent).toBe('common.close');
});
it('FE-COMP-JOURNEYPDF-010: prints mood and weather next to the entry', async () => {
const journey = buildJourney();
journey.entries[0].mood = 'good';
journey.entries[0].weather = 'rainy';
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
const html = getIframe()!.srcdoc;
expect(html).toContain('journey.mood.good');
expect(html).toContain('journey.weather.rainy');
// Icons come along as inline SVG inside the chips.
expect(html).toContain('class="entry-chips"');
expect(html).toContain('<svg');
});
it('FE-COMP-JOURNEYPDF-011: skips chips for values the app does not know', async () => {
// The fixture carries mood 'excited', which is not one of the four moods.
await downloadJourneyBookPDF(buildJourney(), { t, locale: 'en-US' });
const html = getIframe()!.srcdoc;
// Scoped to the chip row: the page elsewhere is free to carry whatever the
// fixture happens to leave undefined, and that is not what this asserts.
const chips = html.match(/<div class="entry-chips">[\s\S]*?<\/div>/)?.[0] ?? '';
expect(chips).not.toBe('');
expect(chips).not.toContain('undefined');
expect(chips).not.toContain('journey.mood.');
// The known weather value still gets its chip.
expect(chips).toContain('journey.weather.sunny');
});
it('FE-COMP-JOURNEYPDF-012: leaves out the chip row when an entry has neither', async () => {
const journey = buildJourney();
journey.entries[0].mood = null;
journey.entries[0].weather = null;
await downloadJourneyBookPDF(journey, { t, locale: 'en-US' });
expect(getIframe()!.srcdoc).not.toContain('class="entry-chips"');
});
});
@@ -1,391 +0,0 @@
// Journey Photo Book PDF — Polarsteps-inspired, magazine-density
import { createElement } from 'react'
import type { LucideIcon } from 'lucide-react'
import { marked } from 'marked'
import { sanitizeRichTextHtml } from '@trek/shared'
import type { JourneyDetail, JourneyEntry, JourneyPhoto } from '../../store/journeyStore'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { renderIconMarkup } from '../../utils/iconMarkup'
type Translate = (key: string, params?: Record<string, string | number>) => string
function esc(str: string | null | undefined): string {
if (!str) return ''
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
}
function md(str: string | null | undefined): string {
if (!str) return ''
// marked passes embedded raw HTML through by default, so sanitise the result
// before it goes into the srcdoc iframe (keeps prose markup, drops scripts).
return sanitizeRichTextHtml(marked.parse(str, { async: false, breaks: true }) as string)
}
function abs(url: string | null | undefined): string {
if (!url) return ''
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) return url
return window.location.origin + (url.startsWith('/') ? '' : '/') + url
}
function pSrc(p: JourneyPhoto): string {
return abs(`/api/photos/${p.photo_id}/original`)
}
function fmtDate(d: string, locale: string): string {
const date = new Date(d + 'T00:00:00')
return date.toLocaleDateString(locale, { weekday: 'long', month: 'long', day: 'numeric', year: 'numeric' })
}
function groupByDate(entries: JourneyEntry[]): Map<string, JourneyEntry[]> {
const groups = new Map<string, JourneyEntry[]>()
for (const e of entries) {
if (!e.entry_date) continue
if (!groups.has(e.entry_date)) groups.set(e.entry_date, [])
groups.get(e.entry_date)!.push(e)
}
return groups
}
function renderProscons(entry: JourneyEntry, tr: Translate): string {
const pc = entry.pros_cons
if (!pc) return ''
const pros = pc.pros?.filter(p => p.trim()) || []
const cons = pc.cons?.filter(c => c.trim()) || []
if (pros.length === 0 && cons.length === 0) return ''
return `<div class="verdict-wrap"><div class="verdict-row">
${pros.length > 0 ? `<div class="verdict-card pros"><div class="verdict-label">${esc(tr('journey.verdict.lovedIt'))}</div><ul>${pros.map(p => `<li>${esc(p)}</li>`).join('')}</ul></div>` : ''}
${cons.length > 0 ? `<div class="verdict-card cons"><div class="verdict-label">${esc(tr('journey.verdict.couldBeBetter'))}</div><ul>${cons.map(c => `<li>${esc(c)}</li>`).join('')}</ul></div>` : ''}
</div></div>`
}
function chip(icon: LucideIcon, label: string, bg: string, color: string): string {
const svg = renderIconMarkup(createElement(icon, { size: 12, strokeWidth: 2, color }))
return `<span class="chip" style="background:${bg};color:${color}">${svg}${esc(label)}</span>`
}
/**
* Mood and weather were on screen but never in the book (#1848). Unknown values
* (older entries, a provider-set category we don't render) drop out silently,
* the same way MoodChip/WeatherChip return null for them.
*/
function renderMoodWeather(entry: JourneyEntry, tr: Translate): string {
const chips: string[] = []
const mood = entry.mood ? MOOD_CONFIG[entry.mood] : undefined
if (mood) chips.push(chip(mood.icon, tr(mood.label), mood.bg, mood.text))
const weather = entry.weather ? WEATHER_CONFIG[entry.weather] : undefined
if (weather) chips.push(chip(weather.icon, tr(weather.label), '#f4f4f5', '#3f3f46'))
if (chips.length === 0) return ''
return `<div class="entry-chips">${chips.join('')}</div>`
}
function renderPhotoBlock(photos: JourneyPhoto[]): string {
if (photos.length === 0) return ''
if (photos.length === 1) {
return `<div class="entry-photo-single"><img src="${pSrc(photos[0])}" /></div>`
}
if (photos.length === 2) {
return `<div class="entry-photo-duo">${photos.map(p => `<div class="photo-cell"><img src="${pSrc(p)}" /></div>`).join('')}</div>`
}
// 3+ photos: hero left + stack right
return `<div class="entry-photo-trio">
<div class="photo-hero"><img src="${pSrc(photos[0])}" /></div>
<div class="photo-stack">
<div class="photo-cell"><img src="${pSrc(photos[1])}" /></div>
<div class="photo-cell"><img src="${pSrc(photos[2])}" /></div>
</div>
</div>`
}
/**
* `opts` stays optional so the signature is additive, but every caller passes
* the page's `t`/`locale` — without them the book falls back to raw keys (#1848).
*/
export async function downloadJourneyBookPDF(
journey: JourneyDetail,
opts: { t?: Translate; locale?: string } = {},
) {
const tr: Translate = opts.t || (key => key)
const loc = opts.locale || 'en'
const entries = (journey.entries || []).filter(e => e.type !== 'skeleton')
const allPhotos = entries.flatMap(e => e.photos || [])
const coverUrl = journey.cover_image ? abs(`/uploads/${journey.cover_image}`) : (allPhotos[0] ? pSrc(allPhotos[0]) : '')
const grouped = groupByDate(entries)
const dates = [...grouped.keys()].sort()
// Build entry pages — one per entry, day header inline on first entry of day
const entryPages: string[] = []
let pageNum = 1 // cover=1
dates.forEach((date, di) => {
const dayEntries = grouped.get(date)!
dayEntries.forEach((entry, ei) => {
pageNum++
const isFirstOfDay = ei === 0
const photos = entry.photos || []
const meta = [entry.entry_time, entry.location_name].filter(Boolean).join(' · ')
// Day header (inline, only on first entry of day)
const dayHeaderHtml = isFirstOfDay
? `<div class="day-header">${esc(tr('journey.detail.day', { number: di + 1 }))} · ${esc(fmtDate(date, loc))}</div>`
: ''
// Photo block
const photoHtml = renderPhotoBlock(photos)
// Pros/cons
const prosconsHtml = renderProscons(entry, tr)
// Story (markdown)
const storyHtml = entry.story ? `<div class="entry-story">${md(entry.story)}</div>` : ''
entryPages.push(`
<div class="entry-page">
${dayHeaderHtml}
${photoHtml}
<div class="entry-content">
${meta ? `<div class="entry-meta">${esc(meta)}</div>` : ''}
${renderMoodWeather(entry, tr)}
${entry.title ? `<h2 class="entry-title">${esc(entry.title)}</h2>` : ''}
${storyHtml}
${prosconsHtml}
</div>
</div>
`)
})
})
const totalPages = pageNum + 1 // +1 for closing page
const html = `<!DOCTYPE html>
<html lang="${esc(loc.split('-')[0])}">
<head>
<meta charset="UTF-8">
<!-- The page is A4 landscape and laid out in pt, so it is about 1123px wide. Without
this, a phone lays the preview out at its default 980px and the document spills
out of the frame, which reads as being zoomed far in. Declaring the real width
makes the browser scale the whole page to fit instead. Printing is unaffected,
it goes by @page. -->
<meta name="viewport" content="width=1123">
<base href="${window.location.origin}/">
<title>${esc(journey.title)} — ${esc(tr('journey.pdf.journeyBook'))}</title>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800;900&display=swap" rel="stylesheet">
<style>
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: 'Inter', -apple-system, sans-serif; color: #1A1A1A; font-size: 11pt; line-height: 1.55; -webkit-print-color-adjust: exact; print-color-adjust: exact; }
img { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
@page { size: A4 landscape; margin: 0; }
/* ── Cover ─── */
.cover-page {
width: 100%; height: 100vh; position: relative; overflow: hidden;
background: #0a0a0f; color: white; display: flex; align-items: center; justify-content: center;
page-break-after: always;
}
.cover-bg { position: absolute; inset: 0; background-size: cover; background-position: center; }
.cover-dim { position: absolute; inset: 0; background: rgba(0,0,0,0.5); }
.cover-mesh { position: absolute; inset: 0; background: radial-gradient(circle at 20% 30%, rgba(99,102,241,0.2), transparent 50%), radial-gradient(circle at 80% 70%, rgba(236,72,153,0.15), transparent 50%); }
.cover-content { position: relative; z-index: 2; text-align: center; padding: 60pt; }
.cover-label { font-size: 9pt; font-weight: 700; letter-spacing: 6pt; text-transform: uppercase; opacity: 0.35; margin-bottom: 24pt; }
.cover-content h1 { font-size: 56pt; font-weight: 800; letter-spacing: -0.03em; line-height: 0.9; margin-bottom: 10pt; }
.cover-content .sub { font-size: 14pt; font-weight: 400; opacity: 0.7; margin-bottom: 36pt; }
.cover-stats { display: flex; gap: 48pt; justify-content: center; }
.cover-stat-val { font-size: 32pt; font-weight: 800; letter-spacing: -0.02em; }
.cover-stat-label { font-size: 10pt; text-transform: uppercase; letter-spacing: 2pt; opacity: 0.4; margin-top: 3pt; }
.cover-footer { position: absolute; bottom: 20pt; left: 0; right: 0; text-align: center; font-size: 9pt; opacity: 0.2; letter-spacing: 3pt; text-transform: uppercase; }
/* ── TOC ─── */
.toc-page {
width: 100%; height: 100vh; padding: 48pt 64pt; display: flex; flex-direction: column;
background: white; page-break-after: always;
}
.toc-top-label { font-size: 9pt; font-weight: 700; letter-spacing: 5pt; text-transform: uppercase; color: #94a3b8; margin-bottom: 16pt; }
.toc-title-block h2 { font-size: 36pt; font-weight: 800; letter-spacing: -1pt; color: #0a0a0f; margin-bottom: 4pt; }
.toc-title-block .sub { font-size: 13pt; color: #71717a; margin-bottom: 24pt; }
.toc-divider { height: 1pt; background: #e4e4e7; margin: 16pt 0; }
.toc-body { flex: 1; columns: 2; column-gap: 40pt; }
.toc-day { break-inside: avoid; margin-bottom: 14pt; }
.toc-day-label { font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase; color: #71717a; margin-bottom: 4pt; }
.toc-entry { display: flex; align-items: baseline; gap: 4pt; font-size: 11pt; color: #3f3f46; margin-bottom: 2pt; }
.toc-entry .toc-title { font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 200pt; }
.toc-entry .toc-dots { flex: 1; border-bottom: 1pt dotted #d4d4d8; margin: 0 4pt; min-width: 20pt; }
.toc-entry .toc-page { font-size: 10pt; color: #a1a1aa; font-weight: 500; flex-shrink: 0; }
.toc-stats { display: flex; gap: 32pt; margin-top: auto; padding-top: 16pt; border-top: 1pt solid #e4e4e7; }
.toc-stat-val { font-size: 18pt; font-weight: 800; color: #0a0a0f; }
.toc-stat-label { font-size: 9pt; text-transform: uppercase; letter-spacing: 1pt; color: #94a3b8; }
/* ── Entry Page ─── */
.entry-page {
width: 100%; min-height: 100vh; padding: 56pt 48pt 48pt;
page-break-after: always;
display: flex; flex-direction: column;
}
/* Day header — inline */
.day-header {
font-size: 9pt; font-weight: 600; letter-spacing: 0.16em; text-transform: uppercase;
color: #71717a; text-align: center; margin-bottom: 16pt; position: relative;
display: flex; align-items: center; gap: 12pt;
}
.day-header::before, .day-header::after { content: ''; flex: 1; height: 0.5pt; background: #d4d4d8; }
/* Photos */
.entry-photo-single { border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 55vh; }
.entry-photo-single img { width: 100%; height: 100%; object-fit: cover; display: block; }
.entry-photo-duo { display: grid; grid-template-columns: 1fr 1fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 45vh; }
.entry-photo-trio { display: grid; grid-template-columns: 3fr 2fr; gap: 6pt; border-radius: 8pt; overflow: hidden; margin-bottom: 16pt; height: 50vh; }
.photo-cell { overflow: hidden; }
.photo-cell img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-hero { overflow: hidden; }
.photo-hero img { width: 100%; height: 100%; object-fit: cover; display: block; }
.photo-stack { display: flex; flex-direction: column; gap: 6pt; }
.photo-stack .photo-cell { flex: 1; }
/* Entry content */
.entry-content { flex: 1; }
.entry-meta { font-size: 10pt; letter-spacing: 0.04em; text-transform: uppercase; color: #71717a; font-weight: 500; margin-bottom: 6pt; }
.entry-chips { display: flex; flex-wrap: wrap; gap: 5pt; margin-bottom: 8pt; }
.chip { display: inline-flex; align-items: center; gap: 3pt; padding: 2.5pt 7pt; border-radius: 999pt; font-size: 8.5pt; font-weight: 600; }
.chip svg { width: 9pt; height: 9pt; }
h2.entry-title { font-size: 28pt; font-weight: 700; letter-spacing: -0.02em; line-height: 1.1; margin: 0 0 10pt; color: #0a0a0f; }
.entry-story { font-size: 11pt; line-height: 1.65; color: #3f3f46; }
.entry-story p { margin: 0 0 8pt; }
.entry-story strong { font-weight: 600; color: #0a0a0f; }
.entry-story em { font-style: italic; }
.entry-story blockquote { margin: 12pt 0; padding-left: 12pt; border-left: 2pt solid #d4d4d8; font-style: italic; color: #52525b; }
.entry-story ul, .entry-story ol { margin: 8pt 0; padding-left: 16pt; }
.entry-story li { margin-bottom: 4pt; }
.entry-story a { color: #2563eb; text-decoration: none; }
/* Verdict */
.verdict-wrap { break-inside: avoid; padding-top: 14pt; }
.verdict-row { display: flex; gap: 10pt; }
.verdict-card { flex: 1; padding: 10pt 12pt; border-radius: 6pt; font-size: 9.5pt; }
.verdict-card.pros { background: #f0fdf4; border: 0.5pt solid #bbf7d0; }
.verdict-card.cons { background: #fef2f2; border: 0.5pt solid #fecaca; }
.verdict-label { font-size: 8pt; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase; margin-bottom: 6pt; }
.verdict-card.pros .verdict-label { color: #15803d; }
.verdict-card.cons .verdict-label { color: #b91c1c; }
.verdict-card ul { margin: 0; padding: 0; list-style: none; }
.verdict-card li { padding: 2pt 0; position: relative; padding-left: 10pt; }
.verdict-card li::before { content: '•'; position: absolute; left: 0; }
.verdict-card.pros li { color: #14532d; }
.verdict-card.pros li::before { color: #22c55e; }
.verdict-card.cons li { color: #7f1d1d; }
.verdict-card.cons li::before { color: #ef4444; }
/* ── Closing ─── */
.closing-page {
width: 100%; height: 100vh; display: flex; align-items: center; justify-content: center;
background: #0a0a0f; color: white; text-align: center; page-break-after: auto;
}
.closing-title { font-size: 32pt; font-weight: 300; letter-spacing: -1pt; opacity: 0.6; margin-bottom: 8pt; }
.closing-sub { font-size: 10pt; opacity: 0.25; letter-spacing: 3pt; text-transform: uppercase; }
/* ── Print ─── */
@media print {
.print-bar { display: none !important; }
body { margin: 0; }
.entry-page { orphans: 3; widows: 3; }
h2.entry-title { page-break-after: avoid; }
.verdict-row { page-break-inside: avoid; }
.entry-photo-single, .entry-photo-duo, .entry-photo-trio { page-break-after: avoid; }
}
</style>
</head>
<body>
<!-- Page 1: Cover -->
<div class="cover-page">
${coverUrl ? `<div class="cover-bg" style="background-image:url('${coverUrl}')"></div>` : ''}
<div class="cover-dim"></div>
<div class="cover-mesh"></div>
<div class="cover-content">
<div class="cover-label">${esc(tr('journey.pdf.journeyBook'))}</div>
<h1>${esc(journey.title)}</h1>
${journey.subtitle ? `<div class="sub">${esc(journey.subtitle)}</div>` : ''}
<div class="cover-stats">
<div><div class="cover-stat-val">${dates.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.days'))}</div></div>
<div><div class="cover-stat-val">${entries.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.entries'))}</div></div>
<div><div class="cover-stat-val">${allPhotos.length}</div><div class="cover-stat-label">${esc(tr('journey.stats.photos'))}</div></div>
</div>
</div>
<div class="cover-footer">${esc(tr('journey.pdf.madeWith'))}</div>
</div>
<!-- Entry Pages -->
${entryPages.join('\n')}
<!-- Closing Page -->
<div class="closing-page">
<div>
<div class="closing-title">${esc(tr('journey.pdf.theEnd'))}</div>
<div class="closing-sub">${esc(tr('journey.pdf.madeWith'))} · ${new Date().getFullYear()}</div>
</div>
</div>
</body>
</html>`
// Render in a fixed overlay + srcdoc iframe — same pattern as TripPDF.
// This avoids window.open() which Safari iOS blocks in async callbacks
// and window.close() which doesn't work reliably in standalone PWA mode.
const overlay = document.createElement('div')
overlay.id = 'journey-pdf-overlay'
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove() }
// The chrome needs a stylesheet rather than inline styles: on a phone the title
// and the two labels do not fit on one row, and translated labels are longer
// than the English ones. Below 640px the card goes full-bleed, the decorative
// title steps aside and the two actions share the width (#1848).
const style = document.createElement('style')
style.textContent = `
#journey-pdf-overlay .jp-card { width:100%;max-width:1100px;height:95vh;background:#fff;border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); }
#journey-pdf-overlay .jp-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid #e4e4e7;flex-shrink:0;background:#0f172a; }
#journey-pdf-overlay .jp-title { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; }
#journey-pdf-overlay .jp-actions { display:flex;align-items:center;gap:8px;flex:none; }
#journey-pdf-overlay .jp-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; }
#journey-pdf-overlay .jp-save { border:none;background:#fff;color:#0f172a; }
#journey-pdf-overlay .jp-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); }
@media (max-width: 640px) {
#journey-pdf-overlay { padding:0; }
#journey-pdf-overlay .jp-card { height:100dvh;max-width:none;border-radius:0; }
#journey-pdf-overlay .jp-head { padding:8px 10px; }
#journey-pdf-overlay .jp-title { display:none; }
#journey-pdf-overlay .jp-actions { flex:1; }
#journey-pdf-overlay .jp-btn { flex:1;padding:10px 12px;font-size:13px; }
}
`
const card = document.createElement('div')
card.className = 'jp-card'
const header = document.createElement('div')
header.className = 'jp-head'
header.innerHTML = `
<span class="jp-title">${esc(journey.title)} &middot; ${totalPages} ${esc(tr('journey.pdf.pages'))}</span>
<div class="jp-actions">
<button id="journey-pdf-save" class="jp-btn jp-save">${esc(tr('journey.pdf.saveAsPdf'))}</button>
<button id="journey-pdf-close" class="jp-btn jp-close">${esc(tr('common.close'))}</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = 'flex:1;width:100%;border:none;'
// No script runs inside the document (print is triggered from the parent via
// contentWindow.print()), so withhold allow-scripts to keep the sandbox tight.
iframe.sandbox = 'allow-same-origin allow-modals'
iframe.srcdoc = html
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(style)
overlay.appendChild(card)
document.body.appendChild(overlay)
header.querySelector<HTMLButtonElement>('#journey-pdf-close')!.onclick = () => overlay.remove()
header.querySelector<HTMLButtonElement>('#journey-pdf-save')!.onclick = () => { iframe.contentWindow?.print() }
}
@@ -0,0 +1,176 @@
import type { BookDocument } from '@trek/shared'
import { SpreadView } from './SpreadView'
import { MARK_LENGTH, MARK_WEIGHT, sheetBox, sheetsFor, type Sheet, type SheetMode } from './bookSheets'
/**
* The book laid out for a press.
*
* ── One renderer ─────────────────────────────────────────────────────────
*
* These are the same `SpreadView`s the editor draws, at 1:1 with `print` on.
* That is the whole reason Studio renders in DOM and positions in millimetres:
* a second renderer for output is a second renderer to proofread against, and
* the day it drifts is the day someone finds out by opening a printed book.
*
* ── What is added on top ─────────────────────────────────────────────────
*
* Only what a press needs and a screen does not: the bleed carried out past the
* trim on all four sides, and crop marks outside it saying where to cut.
* Nothing here changes where anything sits on the page.
*/
export function BookSheetsView({
doc, mode, marks,
}: {
doc: BookDocument
mode: SheetMode
/** Crop marks, and the room around the bleed they need. */
marks: boolean
}) {
const sheets = sheetsFor(doc, mode)
return (
<div className="bx-book">
{sheets.map((sheet, i) => (
<SheetView
key={`${sheet.spread.id}-${sheet.offset}`}
sheet={sheet}
doc={doc}
marks={marks}
last={i === sheets.length - 1}
/>
))}
</div>
)
}
function SheetView({
sheet, doc, marks, last,
}: {
sheet: Sheet
doc: BookDocument
marks: boolean
last: boolean
}) {
/*
* Each sheet is sized to what is on it, not to the widest in the book.
*
* A cover is one page wide and a spread is two, and in spread mode they sit
* in the same document. Making every sheet the wider of the two — so that one
* `@page` rule would cover them all — put a cover in the middle of a
* spread-sized page with white either side, which is not a cover. The print
* CSS carries a named page rule for the narrow ones instead.
*/
const box = sheetBox(sheet.width, sheet.height, doc.page.bleed, marks)
return (
<div
className={`bx-sheet${sheet.single ? ' is-single' : ''}`}
data-label={sheet.label}
style={{
position: 'relative',
width: `${box.width}mm`,
height: `${box.height}mm`,
background: '#ffffff',
// The last sheet takes no break after it: an empty final page is the
// most common defect in a browser-printed PDF.
breakAfter: last ? 'auto' : 'page',
overflow: 'hidden',
}}
>
{/*
The window on the spread. One page wide plus bleed on both sides, which
is why a photograph crossing the gutter comes out aligned: both halves
are the same spread seen through openings a page apart.
*/}
<div
style={{
position: 'absolute',
left: `${box.margin - box.bleed}mm`,
top: `${box.margin - box.bleed}mm`,
width: `${sheet.width + box.bleed * 2}mm`,
height: `${sheet.height + box.bleed * 2}mm`,
// The spread's own paper colour, carried out into the bleed. Without
// it a coloured page would be cut with a white edge on three sides.
background: sheet.spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
overflow: 'hidden',
}}
>
<div
style={{
position: 'absolute',
left: `${box.bleed - sheet.offset}mm`,
top: `${box.bleed}mm`,
width: `${sheet.spreadWidth}mm`,
height: `${sheet.height}mm`,
}}
>
<SpreadView
spread={sheet.spread}
page={doc.page}
spreadIndex={sheet.spreadIndex}
big
print
/>
</div>
</div>
{marks && <CropMarks box={box} width={sheet.width} height={sheet.height} />}
</div>
)
}
/**
* Where to cut.
*
* Eight hairlines, two per corner, starting at the bleed edge and reaching
* outward — never crossing the trim, because a mark printed inside the trim is
* a mark that stays in the finished book.
*/
function CropMarks({
box, width, height,
}: {
box: ReturnType<typeof sheetBox>
width: number
height: number
}) {
const left = box.margin
const top = box.margin
const right = left + width
const bottom = top + height
const gap = box.bleed
const line = (x: number, y: number, w: number, h: number, key: string) => (
<div
key={key}
style={{
position: 'absolute',
left: `${x}mm`,
top: `${y}mm`,
width: `${w}mm`,
height: `${h}mm`,
background: '#000000',
}}
/>
)
const marks = []
for (const [x, dx] of [[left, -1], [right, 1]] as const) {
for (const [y, dy] of [[top, -1], [bottom, 1]] as const) {
// Horizontal arm, reaching out from the corner past the bleed.
marks.push(line(
dx < 0 ? x - gap - MARK_LENGTH : x + gap,
y - MARK_WEIGHT / 2,
MARK_LENGTH, MARK_WEIGHT,
`h${x}-${y}`,
))
// Vertical arm.
marks.push(line(
x - MARK_WEIGHT / 2,
dy < 0 ? y - gap - MARK_LENGTH : y + gap,
MARK_WEIGHT, MARK_LENGTH,
`v${x}-${y}`,
))
}
}
return <>{marks}</>
}
@@ -0,0 +1,98 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { fontStack } from './bookFonts'
import { folioInk } from './folioColour'
/**
* The folios.
*
* Drawn by the renderer rather than stored as elements, because the number a
* page carries is a function of where the spread sits in the book — and an
* element holding "14" would say 14 after the spread was moved, duplicated or
* deleted. Nothing else in the document has that property, which is why this is
* the one thing on the page that is not an element.
*
* That also settles where it belongs: inside `SpreadView`, so the editor and
* the print renderer produce the same number. Stamping them with pdf-lib
* afterwards would mean proofreading a book on screen whose page numbers are
* not the ones that get printed.
*
* The cover and the back cover carry none. A folio on a cover is a mistake in
* every book ever bound.
*/
export function PageNumbers({
spread, page, spreadIndex,
}: {
spread: BookSpread
page: BookPageSetup
/** Position in the document, cover included. */
spreadIndex: number
}) {
const cfg = page.pageNumbers
if (!cfg?.show || spread.role !== 'inner') return null
/*
* The cover is one sheet with a front and a back, so the first inner spread
* opens on `startAt` — 2 by default, the page you see when you open a book
* whose cover is page 1. `spreadIndex` counts the cover, hence the -1.
*/
const left = cfg.startAt + (spreadIndex - 1) * 2
const right = left + 1
const size = cfg.size
const y = page.pageHeight - cfg.margin
/** Where the number sits on each page, given which edge it hangs from. */
const place = (side: 'left' | 'right') => {
const pageX = side === 'left' ? 0 : page.pageWidth
if (cfg.position === 'centre') {
return { x: pageX, w: page.pageWidth, align: 'center' as const }
}
// Outer is the cut edge, inner is the gutter — the distinction only exists
// on a spread, and getting it backwards puts both numbers in the fold.
const outward = cfg.position === 'outer'
const atLeftEdge = side === 'left' ? outward : !outward
return atLeftEdge
? { x: pageX + cfg.margin, w: page.pageWidth * 0.4, align: 'left' as const }
: { x: pageX + page.pageWidth * 0.6 - cfg.margin, w: page.pageWidth * 0.4, align: 'right' as const }
}
return (
<>
{([['left', left], ['right', right]] as const).map(([side, number]) => {
const at = place(side)
/*
* Sampled at the middle of the number's own box, which is where the
* digits actually are — the box is 40% of the page wide so that the
* text can align inside it, and its left edge is often over something
* else entirely.
*/
const ink = cfg.autoColor
? folioInk(spread, at.x + at.w / 2, y - size * 0.18)
: { color: cfg.color, shadow: undefined }
return (
<div
key={side}
style={{
position: 'absolute',
left: `${at.x}mm`,
top: `${y}mm`,
width: `${at.w}mm`,
textAlign: at.align,
fontFamily: fontStack(cfg.font),
fontSize: `${size}pt`,
fontWeight: 500,
letterSpacing: '0.08em',
lineHeight: 1,
color: ink.color,
textShadow: ink.shadow,
fontVariantNumeric: 'tabular-nums',
pointerEvents: 'none',
}}
>
{number}
</div>
)
})}
</>
)
}
@@ -0,0 +1,52 @@
import type { BookPeer } from './useBookPresence'
import { peerColour } from './peerColour'
/**
* Who else has the book open.
*
* In the bar rather than in a panel, because it answers a question people ask
* before they start moving things: am I the only one in here. A pointer answers
* it too, but only once the other person moves — someone reading a spread in
* silence is invisible without this.
*
* The same colour as their pointer, so the two read as one person.
*/
export function PeerBadges({ peers, t }: { peers: BookPeer[]; t: (k: string) => string }) {
if (peers.length === 0) return null
/*
* By person, not by socket.
*
* The presence list is keyed by socket because two tabs are two pointers, and
* that is right for pointers. It is wrong here: one person with the book open
* twice is one person, and showing them as two says the room is busier than
* it is.
*/
const seen = new Map<number, BookPeer>()
for (const p of peers) if (!seen.has(p.userId)) seen.set(p.userId, p)
const people = [...seen.values()]
const shown = people.slice(0, 4)
const rest = people.length - shown.length
return (
<div className="st-peers" title={people.map(p => p.username).join(', ')}>
<span className="st-peer-faces">
{shown.map(p => (
<span
key={p.userId}
className="st-peer-dot"
style={{ background: peerColour(p.userId) }}
aria-label={p.username}
>
{p.avatar
? <img src={p.avatar} alt="" />
: (p.username?.[0] ?? '?').toUpperCase()}
</span>
))}
</span>
{rest > 0 && <span className="st-peer-more">+{rest}</span>}
<span className="st-peer-label">{t('journey.studio.peersHere')}</span>
</div>
)
}
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react'
import type { PeerCursor } from './useBookPresence'
import { peerColour } from './peerColour'
/**
* The other people's pointers.
*
* ── Why they are interpolated ────────────────────────────────────────────
*
* They arrive about ten times a second, because sending sixty would be five
* times the traffic for something the eye cannot use. Drawn as they arrive,
* that reads as a stutter; drawn towards where they last were, it reads as a
* hand moving. The catch-up is deliberately quick — a pointer that eases
* elegantly into place is a pointer that is telling you where somebody was a
* third of a second ago.
*
* The colour is derived from the person's id — see peerColour.ts.
*/
/** How much of the remaining distance is covered per frame. */
const EASE = 0.28
/** Below this, in millimetres, the pointer is simply put where it belongs. */
const SNAP_MM = 0.15
export function PeerCursors({
cursors, spreadIndex, zoom,
}: {
cursors: PeerCursor[]
/** Only the pointers on the spread being looked at are drawn. */
spreadIndex: number
/** The sheet's scale, so the arrow can undo it and stay one size. */
zoom: number
}) {
const here = cursors.filter(c => c.spreadIndex === spreadIndex)
if (here.length === 0) return null
return (
<>
{here.map(cursor => (
<Pointer key={cursor.socketId} cursor={cursor} zoom={zoom} />
))}
</>
)
}
function Pointer({ cursor, zoom }: { cursor: PeerCursor; zoom: number }) {
const [at, setAt] = useState({ x: cursor.x, y: cursor.y })
const target = useRef({ x: cursor.x, y: cursor.y })
const frame = useRef<number | null>(null)
target.current = { x: cursor.x, y: cursor.y }
useEffect(() => {
const step = () => {
setAt(current => {
const dx = target.current.x - current.x
const dy = target.current.y - current.y
if (Math.abs(dx) < SNAP_MM && Math.abs(dy) < SNAP_MM) {
frame.current = null
return target.current
}
frame.current = requestAnimationFrame(step)
return { x: current.x + dx * EASE, y: current.y + dy * EASE }
})
}
if (frame.current == null) frame.current = requestAnimationFrame(step)
return () => {
if (frame.current != null) cancelAnimationFrame(frame.current)
frame.current = null
}
}, [cursor.x, cursor.y])
const colour = peerColour(cursor.userId)
return (
<div
className="st-peer"
style={{
// Placed in the sheet's millimetres, which is what the position means,
// and then scaled back out: an arrow that grew with the zoom would be a
// postage stamp at 30% and a dinner plate at 400%.
left: `${at.x}mm`,
top: `${at.y}mm`,
transform: `scale(${1 / Math.max(zoom, 0.05)})`,
transformOrigin: 'top left',
}}
aria-hidden
>
{/*
The arrow itself. Drawn rather than an icon so the fill can be the
person's colour and the outline can stay white — a solid pointer
disappears into a dark photograph, and a white one into a white page.
*/}
{/*
Bigger than it first was: at 14px the 1.2 outline ate most of the fill
and every pointer read as the same white arrow. The outline is what
keeps it visible on a dark photograph, so the arrow grew instead.
*/}
<svg width="18" height="23" viewBox="0 0 14 18" fill="none">
<path
d="M1 1L1 14.5L4.6 11.3L7.1 16.6L9.6 15.4L7.1 10.2L11.6 10.2L1 1Z"
fill={colour}
stroke="#ffffff"
strokeWidth="1"
strokeLinejoin="round"
/>
</svg>
{cursor.username && (
<span className="st-peer-name" style={{ background: colour }}>
{cursor.username}
</span>
)}
</div>
)
}
@@ -0,0 +1,74 @@
import { AlertTriangle, Check, CloudOff, RefreshCw } from 'lucide-react'
import type { BookRecord } from '@trek/shared'
import type { SaveState } from './useBookStore'
/**
* Whether the book is safe.
*
* Autosave without a sign of it is a promise you are asking someone to take on
* faith, and the moment they do not is the moment they close the tab to check.
* So: a tick when it is written, a spinner while it is being written, and — the
* one that matters — a real choice when someone else got there first.
*
* Quiet by design. This sits in the top bar of an editor, and a status that
* announces every successful save is a status people learn to stop reading,
* which is exactly the wrong habit for the one time it says something else.
*/
export function SaveIndicator({
state, t, onAcceptTheirs, onKeepMine, onRetry,
}: {
state: SaveState
t: (k: string) => string
onAcceptTheirs: (current: BookRecord) => void
onKeepMine: (current: BookRecord) => void
onRetry: () => void
}) {
if (state.status === 'conflict') {
return (
<div className="st-save is-conflict" role="alert">
<AlertTriangle size={13} />
<span>{t('journey.studio.saveConflict')}</span>
{/*
Two ways out, both stated plainly. There is no merge here and
pretending otherwise would be worse than saying so: one of these
documents is going to be the book.
*/}
<button onClick={() => onAcceptTheirs(state.current)}>
{t('journey.studio.saveTakeTheirs')}
</button>
<button onClick={() => onKeepMine(state.current)}>
{t('journey.studio.saveKeepMine')}
</button>
</div>
)
}
if (state.status === 'error') {
return (
<button className="st-save is-error" onClick={onRetry} title={t('journey.studio.saveRetry')}>
<CloudOff size={13} />
<span>{t('journey.studio.saveFailed')}</span>
</button>
)
}
if (state.status === 'saving') {
return (
<div className="st-save">
<RefreshCw size={13} className="st-spin" />
<span>{t('journey.studio.saving')}</span>
</div>
)
}
if (state.status === 'saved') {
return (
<div className="st-save is-quiet">
<Check size={13} />
<span>{t('journey.studio.saved')}</span>
</div>
)
}
return null
}
+523
View File
@@ -0,0 +1,523 @@
import type { CSSProperties } from 'react'
import type {
BookElement, BookPageSetup, BookPhotoElement, BookShapeElement, BookSpread,
} from '@trek/shared'
import { FONT_STACKS, photoSrc } from './bookRender'
import { HOLED_SHAPES, SHAPE_PATHS, scalePath, unitPath } from './shapes'
import { TravelElementView } from './TravelElements'
import { PageNumbers } from './PageNumbers'
/**
* One spread, drawn.
*
* This component is the whole reason Studio renders in DOM rather than on a
* canvas: the *same* tree is what the print renderer will run in headless
* Chromium. Edit mode adds handles and outlines on top; print mode is this and
* nothing else. There is no second renderer to drift against.
*
* Everything is positioned in millimetres. CSS maps mm onto the PDF with a fixed
* ratio, so what you see at `scale(0.4)` is the same box model the printer gets
* at 1:1 — not an approximation of it.
*/
function frameStyle(el: BookElement): CSSProperties {
return {
position: 'absolute',
left: `${el.frame.x}mm`,
top: `${el.frame.y}mm`,
width: `${el.frame.w}mm`,
height: `${el.frame.h}mm`,
opacity: el.opacity,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
}
}
/** Two decimals of a millimetre, matching what the document itself stores. */
const round2 = (n: number) => Math.round(n * 100) / 100
/** #rrggbb plus an alpha, for the fades a cover panel needs. */
function hexToRgba(hex: string, alpha: number): string {
const n = parseInt(hex.slice(1), 16)
return `rgba(${(n >> 16) & 255}, ${(n >> 8) & 255}, ${n & 255}, ${alpha})`
}
const FILTERS: Record<string, string | undefined> = {
none: undefined,
bw: 'grayscale(1) contrast(1.05)',
warm: 'saturate(1.1) sepia(0.16)',
cool: 'saturate(1.05) hue-rotate(-8deg) brightness(1.02)',
// Faded, the way a print left in the light goes: lifted blacks, less colour.
fade: 'saturate(0.72) contrast(0.88) brightness(1.08)',
contrast: 'contrast(1.22) saturate(1.06)',
}
/**
* How much of the frame the decoration around a picture eats, as a fraction of
* the shorter side.
*
* A Polaroid's chin is the reason this is a table rather than one number: the
* bottom border is roughly three times the others, and getting that ratio wrong
* is the difference between a Polaroid and a photo with a white edge.
*/
const FRAME_INSET: Record<string, { pad: number; bottom: number }> = {
none: { pad: 0, bottom: 0 },
polaroid: { pad: 0.055, bottom: 0.17 },
white: { pad: 0.035, bottom: 0.035 },
shadow: { pad: 0, bottom: 0 },
film: { pad: 0.075, bottom: 0.075 },
tape: { pad: 0, bottom: 0 },
}
/**
* A shape, drawn.
*
* Rectangles and ellipses stay `<div>`s — a box with a corner radius is exactly
* what CSS is good at, and those two are what every document written before the
* shape library existed contains. Everything else is an SVG path.
*
* The viewBox is in millimetres and the element is that many millimetres wide,
* so one user unit is one millimetre and a stroke width means the same thing on
* every side. The path is drawn into a box inset by half the stroke, which puts
* the whole stroke inside the frame the editor snapped — the same thing
* `box-sizing: border-box` does for the div case, and the reason a snapped edge
* and a drawn edge line up.
*/
function ShapeView({ el }: { el: BookShapeElement }) {
const fill = el.fill ?? 'transparent'
const gradientId = `g-${el.id}`
const gradient = el.gradient !== 'none' && el.fill
if (el.shape === 'rect' || el.shape === 'ellipse') {
const background = !gradient
? fill
: `linear-gradient(${el.gradient === 'up' ? 'to top' : 'to bottom'},`
+ ` ${hexToRgba(el.fill!, 0)} 0%,`
+ ` ${hexToRgba(el.fill!, 0.55)} 46%,`
+ ` ${hexToRgba(el.fill!, 1)} 100%)`
return (
<div
style={{
...frameStyle(el),
background,
border: el.stroke ? `${el.strokeWidth}mm ${el.strokeStyle} ${el.stroke}` : undefined,
boxSizing: 'border-box',
borderRadius: el.shape === 'ellipse' ? '50%' : el.radius ? `${el.radius}mm` : undefined,
}}
/>
)
}
const sw = el.stroke ? el.strokeWidth : 0
const w = Math.max(0.01, el.frame.w - sw)
const h = Math.max(0.01, el.frame.h - sw)
const d = scalePath(SHAPE_PATHS[el.shape], w, h)
// Proportional to the stroke, so a dashed outline keeps its rhythm whether it
// is a hairline on a caption rule or a 2mm band around a cover panel.
const dash = el.strokeStyle === 'dashed' ? `${sw * 3} ${sw * 2}`
: el.strokeStyle === 'dotted' ? `${sw * 0.01} ${sw * 2}`
: undefined
return (
<div style={frameStyle(el)}>
<svg
width={`${el.frame.w}mm`}
height={`${el.frame.h}mm`}
viewBox={`0 0 ${el.frame.w} ${el.frame.h}`}
style={{ display: 'block', overflow: 'visible' }}
>
{gradient && (
<defs>
<linearGradient id={gradientId} x1="0" y1={el.gradient === 'up' ? '1' : '0'} x2="0" y2={el.gradient === 'up' ? '0' : '1'}>
<stop offset="0%" stopColor={el.fill!} stopOpacity="0" />
<stop offset="46%" stopColor={el.fill!} stopOpacity="0.55" />
<stop offset="100%" stopColor={el.fill!} stopOpacity="1" />
</linearGradient>
</defs>
)}
<path
d={d}
transform={sw ? `translate(${sw / 2} ${sw / 2})` : undefined}
fill={gradient ? `url(#${gradientId})` : fill}
fillRule={HOLED_SHAPES.has(el.shape) ? 'evenodd' : undefined}
stroke={el.stroke ?? undefined}
strokeWidth={sw || undefined}
strokeDasharray={dash}
strokeLinecap={el.strokeStyle === 'dotted' ? 'round' : undefined}
strokeLinejoin="round"
/>
</svg>
</div>
)
}
/**
* A picture — possibly cut to a shape, possibly sitting in something.
*
* The mask and the decoration are independent on purpose: a heart in a Polaroid
* is a silly thing to want and there is no reason to stop anyone wanting it, and
* keeping them separate means neither has to know about the other.
*/
function PhotoView({ el, big, print, dropLabel }: {
el: BookPhotoElement; big: boolean; print: boolean; dropLabel: string
}) {
const deco = FRAME_INSET[el.frameStyle] ?? FRAME_INSET.none
const side = Math.min(el.frame.w, el.frame.h)
// Rounded to a hundredth of a millimetre — the same precision the document
// stores. Without it a fraction of the frame lands in the CSS as
// `10.200000000000001mm`, which is not wrong but is float noise in a
// stylesheet that a person may well end up reading.
const pad = round2(deco.pad * side)
const bottom = round2(deco.bottom * side)
const clipId = `c-${el.id}`
const clipped = el.mask && el.mask !== 'rect'
/*
* An empty frame is a template's promise: it says where a picture goes before
* anyone has chosen which — and it says it *in the frame it will wear*.
*
* This used to render on its own, ignoring frameStyle entirely, so a Polaroid
* placeholder was an ordinary dashed rectangle until a photograph landed in
* it and the chin appeared from nowhere. The two branches have been folded
* together: the surround is drawn once, and only what goes inside it differs.
*/
const empty = el.photoId == null
// In the printed book an unfilled frame is nothing at all — a hatch and an
// instruction on a page someone paid to have bound would be a defect.
if (empty && print) return null
// The label is sized in millimetres so it scales with the zoom exactly as the
// page does, and it steps aside on a frame too small to hold it.
const labelSize = Math.max(2.4, Math.min(4.6, side * 0.085))
const roomy = side - pad * 2 > 22 && el.frame.w - pad * 2 > 34
const hatch = (
<div
style={{
position: 'absolute',
inset: 0,
background:
'repeating-linear-gradient(45deg, rgba(0,0,0,.045) 0 6px, rgba(0,0,0,.02) 6px 12px)',
// A dashed border would be cut in half lengthwise by the clip, so a
// masked placeholder shows its shape through the hatch alone.
border: clipped ? undefined : '1px dashed rgba(0,0,0,.16)',
boxSizing: 'border-box',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '2mm',
}}
>
{roomy && dropLabel && (
<span
style={{
fontFamily: FONT_STACKS.sans,
fontSize: `${labelSize}mm`,
fontWeight: 600,
letterSpacing: '0.16em',
lineHeight: 1.5,
textAlign: 'center',
textTransform: 'uppercase',
color: 'rgba(0,0,0,.34)',
whiteSpace: 'pre-line',
userSelect: 'none',
}}
>
{dropLabel}
</span>
)}
</div>
)
const picture = (
<div
style={{
position: 'absolute',
left: `${pad}mm`,
top: `${pad}mm`,
right: `${pad}mm`,
bottom: `${bottom || pad}mm`,
overflow: 'hidden',
borderRadius: !clipped && el.radius ? `${el.radius}mm` : undefined,
clipPath: clipped ? `url(#${clipId})` : undefined,
}}
>
{empty ? hatch : (
<img
src={photoSrc(el.photoId!, big)}
alt=""
draggable={false}
loading="lazy"
style={{
width: '100%',
height: '100%',
objectFit: el.fit,
objectPosition: `${el.focalX * 100}% ${el.focalY * 100}%`,
filter: FILTERS[el.filter],
display: 'block',
}}
/>
)}
</div>
)
return (
<div
style={{
...frameStyle(el),
background: el.frameStyle === 'polaroid' || el.frameStyle === 'white' ? '#ffffff'
: el.frameStyle === 'film' ? '#141414'
: undefined,
// A photograph lying on a page, rather than printed into it. Soft and
// low: a hard shadow reads as a UI card, not as paper.
boxShadow: el.frameStyle === 'shadow' || el.frameStyle === 'polaroid'
? '0 1.2mm 3mm rgba(0,0,0,.22)'
: undefined,
}}
>
{clipped && (
<svg width="0" height="0" style={{ position: 'absolute' }} aria-hidden>
<defs>
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d={unitPath(SHAPE_PATHS[el.mask!])} />
</clipPath>
</defs>
</svg>
)}
{picture}
{/* Sprocket holes. Drawn as a repeating gradient rather than as elements
so the count follows the width instead of being fixed at a size that
only looks right on one frame. */}
{el.frameStyle === 'film' && [0, 1].map(row => (
<div
key={row}
style={{
position: 'absolute',
left: `${pad * 0.35}mm`,
right: `${pad * 0.35}mm`,
[row ? 'bottom' : 'top']: `${pad * 0.22}mm`,
height: `${pad * 0.42}mm`,
background: 'repeating-linear-gradient(90deg,'
+ ` #f4f4f4 0 ${pad * 0.5}mm, transparent ${pad * 0.5}mm ${pad * 1.1}mm)`,
}}
/>
))}
{/* Two strips of tape, held at an angle across opposite corners. */}
{/*
Two strips, one over each top corner, each centred *on* its corner so it
overhangs the picture evenly on both sides — which is what a piece of
tape does. Offsetting by a fraction of the strip instead left them
sitting beside the corners rather than across them.
*/}
{el.frameStyle === 'tape' && ([-1, 1] as const).map(dir => {
const tapeW = side * 0.26
const tapeH = side * 0.075
return (
<div
key={dir}
style={{
position: 'absolute',
[dir < 0 ? 'left' : 'right']: `${round2(-tapeW / 2)}mm`,
top: `${round2(-tapeH / 2)}mm`,
width: `${round2(tapeW)}mm`,
height: `${round2(tapeH)}mm`,
background: 'rgba(236,228,206,.82)',
boxShadow: '0 .3mm .6mm rgba(0,0,0,.12)',
// Away from the corner on each side: anticlockwise on the left,
// clockwise on the right, so the pair reads as symmetric.
transform: `rotate(${dir * 45}deg)`,
}}
/>
)
})}
</div>
)
}
export function ElementView({
el, big, print = false, dropLabel = '',
}: { el: BookElement; big: boolean; print?: boolean; dropLabel?: string }) {
if (el.kind === 'photo') {
return <PhotoView el={el} big={big} print={print} dropLabel={dropLabel} />
}
if (el.kind === 'shape') return <ShapeView el={el} />
if (el.kind !== 'text') {
// map, stats, countries, badge — the elements drawn from the journey's own
// figures. They live in their own file because they are a different kind of
// drawing: type and vector graphics laid out from data, rather than one box
// with one property set.
return <TravelElementView el={el} frameStyle={frameStyle(el)} />
}
return (
<div
style={{
...frameStyle(el),
color: el.color,
// pt, not px: the document speaks the print's language, and CSS knows
// the conversion exactly.
fontSize: `${el.size}pt`,
fontFamily: FONT_STACKS[el.font],
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
textAlign: el.align,
whiteSpace: 'pre-wrap',
overflow: 'hidden',
hyphens: 'auto',
}}
>
{el.text}
</div>
)
}
/**
* The sheet. `mode="print"` is exactly what the renderer will produce; the
* editor draws the same thing and layers its chrome above it.
*/
export function SpreadView({
spread,
page,
spreadIndex = 0,
big = false,
showGuides = false,
print = false,
dropLabel = '',
}: {
spread: BookSpread
page: BookPageSetup
/**
* Where this spread sits in the book. Only the folios need it — a page
* number is the one thing on the page that is a function of position rather
* than of the document.
*/
spreadIndex?: number
big?: boolean
showGuides?: boolean
/** The print renderer passes this: no guides, no placeholders, no chrome. */
print?: boolean
dropLabel?: string
}) {
const isSingle = spread.role !== 'inner'
const w = isSingle ? page.pageWidth : page.pageWidth * 2
return (
<div
style={{
position: 'absolute',
inset: 0,
background: spread.background ?? '#ffffff', // theme-lint-disable — paper, not app chrome
/*
* On screen the page ends at the trim, so anything past it is clipped.
* In print it must not be: the bleed is the part that runs off the edge
* and gets cut away, and clipping it here would leave a white hairline
* along every edge of the finished book. The sheet does the clipping
* instead — see BookSheetsView, whose window is trim plus bleed.
*/
overflow: print ? 'visible' : 'hidden',
}}
>
{spread.elements.map(el => (
<ElementView key={el.id} el={el} big={big} print={print} dropLabel={dropLabel} />
))}
<PageNumbers spread={spread} page={page} spreadIndex={spreadIndex} />
{showGuides && (
<>
{/* Safe area, per page: on a spread the inner margin belongs to the
gutter, so one box around the whole sheet would be a lie. */}
{(isSingle ? [0] : [0, page.pageWidth]).map(offset => (
<div
key={offset}
style={{
position: 'absolute',
left: `${offset + page.safe}mm`,
top: `${page.safe}mm`,
width: `${page.pageWidth - page.safe * 2}mm`,
height: `${page.pageHeight - page.safe * 2}mm`,
border: '1px dashed rgba(0,0,0,.14)',
pointerEvents: 'none',
}}
/>
))}
<div
aria-hidden
style={{ position: 'absolute', inset: 0, width: `${w}mm`, pointerEvents: 'none' }}
/>
</>
)}
</div>
)
}
/**
* The fold down the middle of an open book.
*
* Preview chrome, not content — which is why it lives outside `SpreadView`. A
* printed book has a physical crease; a *printed* shadow down the gutter would
* be a defect. So the editor and the page thumbnails draw this, and the renderer
* never sees it.
*
* Two layers, because that is what makes paper read as curving rather than as a
* grey stripe: the shadow ramps into the spine and darkens hard at the crease,
* and just outside it a pale band lifts, the way the sheet catches light as it
* comes back up out of the binding.
*/
export function SpreadFold({ page, scaled }: { page: BookPageSetup; scaled: number }) {
// Wide on purpose: the lift has to ramp over a long distance to read as
// paper curving. A narrow band reads as two painted stripes instead.
const width = 52 * scaled
const left = page.pageWidth * scaled - width / 2
return (
<div style={{ position: 'absolute', left, top: 0, width, bottom: 0, pointerEvents: 'none' }}>
<div
style={{
position: 'absolute',
inset: 0,
background:
'linear-gradient(90deg,'
+ ' rgba(255,255,255,0) 0%,'
+ ' rgba(255,255,255,.07) 12%,'
+ ' rgba(255,255,255,.17) 27%,'
+ ' rgba(255,255,255,.11) 37%,'
+ ' rgba(255,255,255,0) 43%,'
+ ' rgba(0,0,0,.035) 45.5%,'
+ ' rgba(0,0,0,.10) 48%,'
+ ' rgba(0,0,0,.18) 49.6%,'
+ ' rgba(0,0,0,.21) 50%,'
+ ' rgba(0,0,0,.18) 50.4%,'
+ ' rgba(0,0,0,.10) 52%,'
+ ' rgba(0,0,0,.035) 54.5%,'
+ ' rgba(255,255,255,0) 57%,'
+ ' rgba(255,255,255,.11) 63%,'
+ ' rgba(255,255,255,.17) 73%,'
+ ' rgba(255,255,255,.07) 88%,'
+ ' rgba(255,255,255,0) 100%)',
}}
/>
{/* The crease itself. Sub-pixel at small zoom, which is right — you should
not see a hard line on a page shown at 15%. */}
<div
style={{
position: 'absolute',
left: '50%',
top: 0,
bottom: 0,
width: Math.max(0.5, 0.35 * scaled),
marginLeft: -Math.max(0.25, 0.175 * scaled),
background: 'rgba(0,0,0,.16)',
}}
/>
</div>
)
}
@@ -0,0 +1,453 @@
import { useEffect, useState } from 'react'
import { ChevronsDown, ChevronsUp, Copy, Lock, RotateCcw, RotateCw, Trash2, Unlock } from 'lucide-react'
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { SpreadFold, SpreadView } from './SpreadView'
import { PeerCursors } from './PeerCursors'
import type { PeerCursor } from './useBookPresence'
import { FONT_STACKS } from './bookRender'
import { useSpreadInteraction, type HandleId } from './useSpreadInteraction'
import { useStudioStore } from '../../store/studioStore'
const HANDLES: HandleId[] = ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']
/**
* Where the four rotation handles sit, as percentages of the selection box.
*
* The push diagonally outwards lives in the stylesheet rather than here: it is
* a fixed number of screen pixels clear of the resize handle on the same
* corner, so it must not scale with the element the way a percentage would.
*/
const ROTATE_CORNERS = [
{ id: 'nw', left: '0%', top: '0%' },
{ id: 'ne', left: '100%', top: '0%' },
{ id: 'se', left: '100%', top: '100%' },
{ id: 'sw', left: '0%', top: '100%' },
] as const
const HANDLE_POS: Record<HandleId, { left: string; top: string; cursor: string }> = {
nw: { left: '0%', top: '0%', cursor: 'nwse-resize' },
n: { left: '50%', top: '0%', cursor: 'ns-resize' },
ne: { left: '100%', top: '0%', cursor: 'nesw-resize' },
e: { left: '100%', top: '50%', cursor: 'ew-resize' },
se: { left: '100%', top: '100%', cursor: 'nwse-resize' },
s: { left: '50%', top: '100%', cursor: 'ns-resize' },
sw: { left: '0%', top: '100%', cursor: 'nesw-resize' },
w: { left: '0%', top: '50%', cursor: 'ew-resize' },
}
/**
* The sheet plus everything you do to it.
*
* The page itself is `SpreadView`, byte for byte what the print renderer will
* draw. Selection outlines, handles and snap guides live in a layer *above* it
* and never touch the document, so nothing you see while editing can end up in
* the book.
*/
export function StudioCanvas({
spread,
spreadIndex,
page,
zoom,
pxPerMm,
bookView,
dropLabel,
cursors,
onCursor,
}: {
spread: BookSpread | null
spreadIndex: number
page: BookPageSetup
zoom: number
pxPerMm: number
bookView: boolean
dropLabel: string
/** The other editors' pointers, on this spread and elsewhere. */
cursors?: PeerCursor[]
/** Where this one is, in the spread's millimetres. Null once it leaves. */
onCursor?: (x: number | null, y: number | null) => void
}) {
const selection = useStudioStore(s => s.selection)
const select = useStudioStore(s => s.select)
const removeElements = useStudioStore(s => s.removeElements)
const duplicate = useStudioStore(s => s.duplicate)
const raise = useStudioStore(s => s.raise)
const commit = useStudioStore(s => s.commit)
const addElement = useStudioStore(s => s.addElement)
const [dropTarget, setDropTarget] = useState<string | null>(null)
const [editing, setEditing] = useState<string | null>(null)
/** Where on the spread, in millimetres, a drop landed. */
/**
* Turn the selection by a number of degrees.
*
* Kept in the range a person reads as an angle rather than as a winding
* number: -180 to 180, so an element turned all the way round reads as
* straight rather than as 360.
*/
const rotateBy = (deg: number) => {
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => {
if (!selection.includes(e.id) || e.locked) return e
let next = (e.rotation + deg) % 360
if (next > 180) next -= 360
if (next < -180) next += 360
return { ...e, rotation: Math.round(next * 10) / 10 }
}),
})),
}))
}
const pointInMm = (e: React.DragEvent) => {
const r = e.currentTarget.getBoundingClientRect()
return { x: (e.clientX - r.left) / scaled, y: (e.clientY - r.top) / scaled }
}
const frameUnder = (x: number, y: number) => spread?.elements.find(el =>
el.kind === 'photo'
&& !el.locked
&& x >= el.frame.x && x <= el.frame.x + el.frame.w
&& y >= el.frame.y && y <= el.frame.y + el.frame.h,
) ?? null
const scaled = pxPerMm * zoom
const { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish } = useSpreadInteraction({
spread, spreadIndex, page, pxPerMm: scaled,
})
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
if (tag === 'INPUT' || tag === 'TEXTAREA') return
if ((e.key === 'Delete' || e.key === 'Backspace') && selection.length) {
e.preventDefault()
removeElements(spreadIndex, selection)
}
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, spreadIndex, removeElements])
if (!spread) return null
const single = spread.role !== 'inner'
const sheetW = single ? page.pageWidth : page.pageWidth * 2
const sel = spread.elements.filter(e => selection.includes(e.id))
return (
<div
className="st-stage"
style={{ width: sheetW * scaled, height: page.pageHeight * scaled }}
onPointerMove={e => {
onPointerMove(e)
if (!onCursor) return
const r = e.currentTarget.getBoundingClientRect()
onCursor((e.clientX - r.left) / scaled, (e.clientY - r.top) / scaled)
}}
// Leaving the stage sends null, so the arrow goes rather than sticking
// where the pointer happened to cross the edge.
onPointerLeave={() => onCursor?.(null, null)}
onPointerUp={finish}
onPointerCancel={finish}
onDragOver={e => {
if (!e.dataTransfer.types.includes('application/x-trek-photo')) return
e.preventDefault()
e.dataTransfer.dropEffect = 'copy'
const p = pointInMm(e)
setDropTarget(frameUnder(p.x, p.y)?.id ?? null)
}}
onDragLeave={() => setDropTarget(null)}
onDrop={e => {
const raw = e.dataTransfer.getData('application/x-trek-photo')
if (!raw) return
e.preventDefault()
setDropTarget(null)
const photoId = Number(raw)
if (!Number.isFinite(photoId)) return
const p = pointInMm(e)
const target = frameUnder(p.x, p.y)
if (target) {
// Dropped onto a frame: fill it. That is the whole point of an empty
// frame, and replacing a picture is the natural way to swap one.
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(el => (el.id === target.id ? { ...el, photoId } : el)),
})),
}))
select([target.id])
return
}
// Dropped on bare paper: a new frame, centred on the cursor.
const w = Math.min(page.pageWidth, page.pageHeight) * 0.5
const h = w * 0.72
const id = `p-${Math.random().toString(36).slice(2, 9)}`
addElement(spreadIndex, {
id, kind: 'photo',
frame: { x: p.x - w / 2, y: p.y - h / 2, w, h },
rotation: 0, opacity: 1, locked: false,
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
mask: null, frameStyle: 'none',
} as BookElement)
select([id])
}}
>
<div
className="st-sheet"
style={{
width: `${sheetW}mm`,
height: `${page.pageHeight}mm`,
transform: `scale(${zoom})`,
}}
onPointerDown={() => select([])}
>
<SpreadView spread={spread} page={page} spreadIndex={spreadIndex} big={zoom > 0.34} showGuides dropLabel={dropLabel} />
{cursors && cursors.length > 0 && (
<PeerCursors cursors={cursors} spreadIndex={spreadIndex} zoom={zoom} />
)}
{/* Hit targets sit above the page so a photo's own <img> never eats the
gesture, and so a locked element simply is not grabbable. */}
{spread.elements.map(el => (
<div
key={el.id}
onPointerDown={e => { if (editing !== el.id) startMove(e, el) }}
onDoubleClick={() => { if (el.kind === 'text' && !el.locked) setEditing(el.id) }}
style={{
position: 'absolute',
left: `${el.frame.x}mm`,
top: `${el.frame.y}mm`,
width: `${el.frame.w}mm`,
height: `${el.frame.h}mm`,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
cursor: el.locked ? 'default' : 'move',
// The target stays live even when locked, or the element could
// not be selected — and an element that cannot be selected cannot
// be unlocked.
pointerEvents: 'auto',
}}
/>
))}
</div>
{/*
Editing happens *on the page*, in the element's own type at the element's
own size. A dialog with a text box would be easier to build and would
make you guess how the words break — the whole reason to type here is to
watch the line endings while you do it.
*/}
{editing && (() => {
const el = spread.elements.find(e => e.id === editing)
if (!el || el.kind !== 'text') return null
return (
<textarea
className="st-inline-edit"
autoFocus
defaultValue={el.text}
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
// The document is in pt; the overlay is on screen at the same zoom.
fontSize: `${el.size * (96 / 72) * zoom}px`,
fontFamily: FONT_STACKS[el.font],
fontWeight: el.weight,
fontStyle: el.italic ? 'italic' : undefined,
lineHeight: el.leading,
letterSpacing: `${el.tracking}em`,
textAlign: el.align,
color: el.color,
}}
onBlur={e => {
const text = e.target.value
setEditing(null)
if (text !== el.text) {
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(x => (x.id === el.id ? { ...x, text, overridden: true } : x)),
})),
}))
}
}}
onKeyDown={e => {
// Escape here means "stop editing", not "close Studio" — so it must
// not reach the shell's handler.
if (e.key === 'Escape') { e.stopPropagation(); (e.target as HTMLTextAreaElement).blur() }
}}
/>
)
})()}
{/* Chrome layer: drawn in screen pixels so outlines stay hairline at any
zoom instead of growing with the page. */}
<div className="st-chrome">
{bookView && !single && <SpreadFold page={page} scaled={scaled} />}
{/* The frame a drop would land in. Without it you are aiming blind. */}
{dropTarget && (() => {
const el = spread.elements.find(e => e.id === dropTarget)
if (!el) return null
return (
<div
className="st-drop"
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
}}
/>
)
})()}
{sel.map(el => (
<div
key={el.id}
className="st-select"
style={{
left: el.frame.x * scaled,
top: el.frame.y * scaled,
width: el.frame.w * scaled,
height: el.frame.h * scaled,
transform: el.rotation ? `rotate(${el.rotation}deg)` : undefined,
}}
>
{/* No resize handles on a locked element: they would offer a drag
that is refused, which reads as the editor being broken rather
than as the element being locked. */}
{/*
A rotation handle diagonally outside each corner.
Four rather than one, because which corner is reachable depends on
where the element sits: a handle off the top right alone is under
the inspector for anything in the right page's upper half.
Positioned in percentages of the selection box, so they follow it
at any zoom and turn with it once it is turned — the box itself
carries the rotation, and everything inside it comes along.
Outside the box rather than on it, because a handle on the corner
is a handle you cannot tell from the one that resizes — and this
is the gesture where grabbing the wrong one is most annoying to
undo. Held to 15° steps unless shift is down.
*/}
{sel.length === 1 && !dragging && !el.locked && ROTATE_CORNERS.map(c => (
<div
key={c.id}
className={`st-rotate is-${c.id}`}
style={{ left: c.left, top: c.top }}
onPointerDown={startRotate}
title="Rotate"
>
<RotateCw size={11} />
</div>
))}
{sel.length === 1 && !dragging && !el.locked && HANDLES.map(h => (
<span
key={h}
className={`st-handle ${h.length === 2 ? 'is-corner' : h === 'n' || h === 's' ? 'is-h' : 'is-v'}`}
style={{ left: HANDLE_POS[h].left, top: HANDLE_POS[h].top, cursor: HANDLE_POS[h].cursor }}
onPointerDown={e => startResize(e, h)}
/>
))}
</div>
))}
{/*
The quick bar, the way Canva and Figma do it: the three or four things
you reach for constantly, right where you are already looking, instead
of a trip to the panel on the far side of the screen. It hides while
you drag — chrome that follows your hand around is noise.
*/}
{sel.length >= 1 && !dragging && (() => {
const x0 = Math.min(...sel.map(e => e.frame.x)) * scaled
const x1 = Math.max(...sel.map(e => e.frame.x + e.frame.w)) * scaled
const y0 = Math.min(...sel.map(e => e.frame.y)) * scaled
const locked = sel.every(e => e.locked)
return (
<div
className="st-quickbar"
style={{ left: (x0 + x1) / 2, top: Math.max(6, y0 - 12) }}
onPointerDown={e => e.stopPropagation()}
>
<button onClick={() => duplicate(spreadIndex, selection)} title="Duplicate">
<Copy size={14} />
</button>
{sel.length === 1 && (
<>
<button onClick={() => raise(spreadIndex, sel[0].id, 'front')} title="Bring to front">
<ChevronsUp size={14} />
</button>
<button onClick={() => raise(spreadIndex, sel[0].id, 'back')} title="Send to back">
<ChevronsDown size={14} />
</button>
</>
)}
{/*
Turn it, a step at a time.
Fifteen degrees per press, and a press with shift is one degree
for the times it has to line up with something. Steps rather
than a handle you drag: a photograph on a page is almost always
either square to it or tilted a little on purpose, and dragging
a rotation past the angle you wanted and back is a worse way to
arrive at either.
*/}
<button
onClick={e => rotateBy(e.shiftKey ? -1 : -15)}
title="Rotate left"
>
<RotateCcw size={14} />
</button>
<button
onClick={e => rotateBy(e.shiftKey ? 1 : 15)}
title="Rotate right"
>
<RotateCw size={14} />
</button>
<span className="st-quickbar-sep" />
<button
onClick={() => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => (selection.includes(e.id) ? { ...e, locked: !locked } : e)),
})),
}))}
title={locked ? 'Unlock' : 'Lock'}
>
{locked ? <Unlock size={14} /> : <Lock size={14} />}
</button>
<span className="st-quickbar-sep" />
<button className="is-danger" onClick={() => removeElements(spreadIndex, selection)} title="Delete">
<Trash2 size={14} />
</button>
</div>
)
})()}
{guides.map((g, i) => (
<div
key={i}
className="st-guide"
style={g.axis === 'x'
? { left: g.at * scaled, top: 0, width: 1, height: '100%' }
: { top: g.at * scaled, left: 0, height: 1, width: '100%' }}
/>
))}
</div>
</div>
)
}
export type { BookElement }
@@ -0,0 +1,325 @@
import { useState } from 'react'
import { Minus, Quote, Square } from 'lucide-react'
import type { BookElement, BookPageSetup, BookShapeId } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS } from './shapes'
import { GRIDS, defaultGridBox, gridElements } from './grids'
import { PanelHead } from './StudioPanelHead'
/**
* Everything you can add that does not come from the journey: type, shapes,
* picture frames and photo grids.
*
* Three tabs rather than three more entries on the rail. They are all the same
* kind of answer to "what do I put on this page", and a rail long enough to
* hold them separately stops being faster to hit than a word would be.
*/
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
type FrameStyle = 'none' | 'polaroid' | 'white' | 'shadow' | 'film' | 'tape'
/**
* A shape as a tile.
*
* The same path the page will draw, so what you pick is what you get. In frame
* mode it is filled with a sky-and-hill gradient, which is Canva's trick and a
* good one: an outline tells you the silhouette, but only something
* photograph-shaped inside it says "a picture goes here".
*/
export function ShapeGlyph({ shape, photo = false }: { shape: BookShapeId; photo?: boolean }) {
const gid = `sg-${photo ? 'p' : 's'}-${shape}`
return (
<svg viewBox="0 0 100 100" width="100%" height="100%" preserveAspectRatio="xMidYMid meet" aria-hidden>
{photo && (
<defs>
{/* theme-lint-disable — a frame preview stands in for a photograph, not for UI chrome */}
<linearGradient id={gid} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor="#cfe8f7" />
<stop offset="52%" stopColor="#e8f2f8" />
<stop offset="53%" stopColor="#8fbf59" />
<stop offset="100%" stopColor="#5d8f33" />
</linearGradient>
</defs>
)}
<path
d={SHAPE_PATHS[shape]}
fill={photo ? `url(#${gid})` : 'currentColor'}
fillRule={HOLED_SHAPES.has(shape) ? 'evenodd' : undefined}
/>
</svg>
)
}
/** A miniature of what the decoration around a picture does. */
function FrameStyleGlyph({ style }: { style: FrameStyle }) {
const pad = style === 'polaroid' ? 3 : style === 'white' ? 2 : style === 'film' ? 4 : 0
const bottom = style === 'polaroid' ? 9 : pad
return (
<span
className="st-frame-glyph"
style={{
// theme-lint-disable — a frame preview shows paper and film, not themed surfaces
background: style === 'film' ? '#141414'
: style === 'polaroid' || style === 'white' ? '#ffffff'
: 'transparent',
boxShadow: style === 'shadow' || style === 'polaroid' ? '0 2px 4px rgba(0,0,0,.35)' : undefined,
}}
>
<span className="st-frame-glyph-photo" style={{ inset: `${pad}px ${pad}px ${bottom}px ${pad}px` }} />
{/* Both strips, because both is what gets placed — a preview showing one
made the second one look like a bug the first time it appeared. */}
{style === 'tape' && <><em className="st-frame-glyph-tape" /><em className="st-frame-glyph-tape is-right" /></>}
</span>
)
}
export function StudioElementsPanel({ page, t }: { page: BookPageSetup; t: (k: string) => string }) {
const [tab, setTab] = useState<'shapes' | 'frames' | 'grids'>('shapes')
const addElement = useStudioStore(s => s.addElement)
const commit = useStudioStore(s => s.commit)
const select = useStudioStore(s => s.select)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
const addText = (size: number, weight: 400 | 500 | 600 | 700, sample: string, extra: Partial<BookElement> = {}) =>
addElement(active, {
id: uid('t'), kind: 'text', frame: centre(page.pageWidth * 0.7, size * 0.5 + 8),
rotation: 0, opacity: 1, locked: false,
text: sample, font: 'sans', size, weight, italic: false,
align: 'left', leading: size > 16 ? 1.1 : 1.5, tracking: size > 16 ? -0.02 : 0,
color: '#1a1a1a', binding: null, overridden: false, ...extra,
} as BookElement)
/**
* Placed at a size that suits the shape rather than always as a square.
*
* A banner wants to be wide and a pin wants to be tall; dropping every shape
* as one 60mm square means resizing each one before it is any use, which is
* a tax on the most common action in the panel.
*/
const addShape = (shape: BookShapeId, opts: { outline?: boolean } = {}) => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.3
const wide = shape.startsWith('banner') || shape === 'ticket' || shape === 'wave'
|| shape === 'arrow-right' || shape === 'arrow-left' || shape === 'arrow-both'
|| shape === 'capsule'
const tall = shape === 'pin' || shape === 'drop' || shape === 'arrow-up' || shape === 'arrow-down'
const isRule = shape === 'line'
addElement(active, {
id: uid('s'), kind: 'shape',
frame: isRule
? centre(page.pageWidth * 0.5, 0.5)
: centre(wide ? side * 1.7 : side, tall ? side * 1.35 : wide ? side * 0.55 : side),
rotation: 0, opacity: 1, locked: false,
// A rule is a shape too — a hairline box rather than its own element type,
// so it moves, colours and snaps like everything else on the page.
shape: isRule ? 'rect' : shape,
fill: opts.outline ? null : '#141414',
gradient: 'none',
stroke: opts.outline ? '#141414' : null,
strokeWidth: opts.outline ? 0.5 : 0,
strokeStyle: 'solid',
radius: 0,
} as BookElement)
}
/** An empty frame, cut to a shape and waiting for a picture. */
const addFrame = (mask: BookShapeId | null, frameStyle: FrameStyle = 'none') => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.45
addElement(active, {
id: uid('p'), kind: 'photo',
// A Polaroid is taller than it is wide, because of the chin. Placing it
// square would put the picture in a box the decoration then eats.
frame: centre(side, frameStyle === 'polaroid' ? side * 1.16 : side),
rotation: 0, opacity: 1, locked: false,
photoId: null, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
mask, frameStyle,
} as BookElement)
}
return (
<>
<PanelHead label={t('journey.studio.elements')} />
<div className="st-tabs">
<button className={tab === 'shapes' ? 'is-on' : ''} onClick={() => setTab('shapes')}>
{t('journey.studio.shapes')}
</button>
<button className={tab === 'frames' ? 'is-on' : ''} onClick={() => setTab('frames')}>
{t('journey.studio.frames')}
</button>
<button className={tab === 'grids' ? 'is-on' : ''} onClick={() => setTab('grids')}>
{t('journey.studio.grids')}
</button>
</div>
<div className="st-panel-scroll">
{tab === 'shapes' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.text')}</div>
<div className="st-stack">
<button className="st-tile" onClick={() => addText(30, 700, t('journey.studio.sampleHeading'))}>
<span style={{ fontSize: 19, fontWeight: 700, letterSpacing: '-0.02em' }}>
{t('journey.studio.styleTitle')}
</span>
</button>
<button className="st-tile" onClick={() => addText(16, 600, t('journey.studio.sampleSubheading'))}>
<span style={{ fontSize: 14, fontWeight: 600 }}>{t('journey.studio.styleSubtitle')}</span>
</button>
<button className="st-tile" onClick={() => addText(10, 400, t('journey.studio.sampleBody'))}>
<span style={{ fontSize: 12.5 }}>{t('journey.studio.styleBody')}</span>
</button>
<button
className="st-tile"
onClick={() => addText(7.5, 600, t('journey.studio.sampleCaption'), { tracking: 0.14, color: '#8a8578' })}
>
<span style={{ fontSize: 10.5, fontWeight: 600, letterSpacing: '0.14em', textTransform: 'uppercase' }}>
{t('journey.studio.styleCaption')}
</span>
</button>
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.lines')}</div>
<div className="st-shape-grid">
<button className="st-shape-btn" onClick={() => addShape('line')} title={t('journey.studio.shapeKind.line')}>
<Minus size={20} strokeWidth={2} />
</button>
<button
className="st-shape-btn"
onClick={() => addShape('rect', { outline: true })}
title={t('journey.studio.shapeKind.outline')}
>
<Square size={20} strokeWidth={1.1} style={{ opacity: .5 }} />
</button>
<button
className="st-shape-btn"
onClick={() => addText(46, 700, '“', { color: '#c9c2b4', leading: 0.9 })}
title={t('journey.studio.quoteMark')}
>
<Quote size={20} strokeWidth={1.6} />
</button>
</div>
</div>
{SHAPE_GROUPS.map(group => (
<div className="st-section" key={group.id}>
<div className="st-section-label">{t(`journey.studio.shapeGroup.${group.id}`)}</div>
<div className="st-shape-grid">
{group.shapes.map(shape => (
<button
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addShape(shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} />
</button>
))}
</div>
</div>
))}
</>
)}
{tab === 'frames' && (
<>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.frameStyles')}</div>
<div className="st-shape-grid">
{([
['none', 'plainFrame'],
['polaroid', 'polaroidFrame'],
['white', 'whiteFrame'],
['shadow', 'shadowFrame'],
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as [FrameStyle, string][]).map(([style, key]) => (
<button
key={style}
className="st-shape-btn is-frame"
onClick={() => addFrame(null, style)}
title={t(`journey.studio.${key}`)}
aria-label={t(`journey.studio.${key}`)}
>
<FrameStyleGlyph style={style} />
</button>
))}
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.frameShapes')}</div>
<div className="st-shape-grid">
{FRAME_SHAPES.map(shape => (
<button
key={shape}
className="st-shape-btn is-glyph"
onClick={() => addFrame(shape === 'rect' ? null : shape)}
aria-label={shape}
>
<ShapeGlyph shape={shape} photo />
</button>
))}
</div>
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.frameHint')}</p>
</div>
</>
)}
{tab === 'grids' && (
<div className="st-section">
<div className="st-section-label">{t('journey.studio.grids')}</div>
<div className="st-grid-grid">
{GRIDS.map(grid => (
<button
key={grid.id}
className="st-grid-btn"
onClick={() => {
const els = gridElements(grid, defaultGridBox(page, single))
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) =>
(i !== active ? sp : { ...sp, elements: [...sp.elements, ...els] })),
}))
// Selected as a group, so the block can be moved or resized
// as one thing straight away rather than cell by cell.
select(els.map(e => e.id))
}}
aria-label={grid.id}
title={`${grid.cells.length}`}
>
{/* The arrangement itself: "hero-two" means nothing until you
have seen it, and a diagram is faster to read than a name. */}
<span className="st-grid-preview">
{grid.cells.map((cell, i) => (
<span
key={i}
style={{
left: `${cell.x * 100}%`,
top: `${cell.y * 100}%`,
width: `${cell.w * 100}%`,
height: `${cell.h * 100}%`,
}}
/>
))}
</span>
</button>
))}
</div>
<p className="st-hint" style={{ paddingTop: 8 }}>{t('journey.studio.gridHint')}</p>
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,194 @@
import { useEffect, useRef, useState } from 'react'
import { BookOpen, FileText, Printer, Scissors, X } from 'lucide-react'
import type { BookDocument } from '@trek/shared'
import { BookSheetsView } from './BookSheetsView'
import { sheetBox, sheetsFor, type SheetMode } from './bookSheets'
import { printSheets } from './printSheets'
/**
* Getting the book out.
*
* ── Two questions, not a settings panel ──────────────────────────────────
*
* Leaves or spreads, and marks or no marks. Everything else a print dialog
* usually asks — resolution, colour profile, embedding — either has one right
* answer here or is not ours to give: the images go out at full size because
* anything else is a worse book, and the profile belongs to whoever is doing
* the printing.
*
* ── Why the sheets are rendered here rather than built as a string ───────
*
* They are the editor's own components, and those read context — the active
* locale, most visibly, which is what a stats element and a date line are
* written in. Rendering them to markup outside the tree would mean standing up
* that context again and getting a book in English for someone who wrote it in
* German. So they render inside the app, off screen, and the markup is taken
* from the DOM afterwards.
*/
export function StudioExport({
doc, title, t, onClose,
}: {
doc: BookDocument
title: string
t: (key: string, params?: Record<string, string | number>) => string
onClose: () => void
}) {
const [mode, setMode] = useState<SheetMode>('pages')
const [marks, setMarks] = useState(true)
/** Set once the user has asked for it — this is what triggers the render. */
const [building, setBuilding] = useState(false)
const stage = useRef<HTMLDivElement>(null)
const sheets = sheetsFor(doc, mode)
/*
* Two sizes, because spread mode mixes them: covers are one page and
* everything between them is two. The wider one is the document's page box
* and the narrower gets a named rule — see printSheets.
*/
const widest = Math.max(...sheets.map(s => s.width), doc.page.pageWidth)
const box = sheetBox(widest, doc.page.pageHeight, doc.page.bleed, marks)
const single = sheetBox(doc.page.pageWidth, doc.page.pageHeight, doc.page.bleed, marks)
useEffect(() => {
if (!building) return
const html = stage.current?.innerHTML
if (!html) return
printSheets({
html,
sheetWidth: box.width,
sheetHeight: box.height,
singleWidth: single.width,
singleHeight: single.height,
title,
labels: {
save: t('journey.studio.exportSave'),
close: t('common.close'),
count: t('journey.studio.exportSheetCount', { count: sheets.length }),
preparing: t('journey.studio.exportPreparing'),
},
})
setBuilding(false)
onClose()
// Runs once per build. Re-running on every render of the options would
// open a second print view behind the first.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [building])
return (
<div className="st-ex" role="dialog" aria-modal="true" aria-label={t('journey.studio.export')}>
<div className="st-ex-card">
<div className="st-ex-head">
<span>{t('journey.studio.export')}</span>
<button className="st-ex-x" onClick={onClose} aria-label={t('common.close')}>
<X size={15} />
</button>
</div>
<div className="st-ex-body">
<Choice<SheetMode>
label={t('journey.studio.exportLayout')}
options={[
{
id: 'pages',
icon: FileText,
name: t('journey.studio.exportPages'),
hint: t('journey.studio.exportPagesHint'),
},
{
id: 'spreads',
icon: BookOpen,
name: t('journey.studio.exportSpreads'),
hint: t('journey.studio.exportSpreadsHint'),
},
]}
value={mode}
onPick={setMode}
/>
<div className="st-ex-field">
<span className="st-ex-label">{t('journey.studio.exportFinishing')}</span>
<button
className={`st-ex-opt ${marks ? 'is-on' : ''}`}
onClick={() => setMarks(!marks)}
aria-pressed={marks}
>
<Scissors size={15} />
<span className="st-ex-text">
<span className="st-ex-name">{t('journey.studio.exportMarks')}</span>
<span className="st-ex-hint">
{t('journey.studio.exportMarksHint', { bleed: doc.page.bleed })}
</span>
</span>
</button>
</div>
<p className="st-ex-note">
{t('journey.studio.exportNote', {
sheets: sheets.length,
width: round1(box.width),
height: round1(box.height),
})}
</p>
</div>
<div className="st-ex-foot">
<button className="st-ex-btn" onClick={onClose}>{t('common.cancel')}</button>
<button className="st-ex-btn is-primary" onClick={() => setBuilding(true)} disabled={building}>
<Printer size={14} />
<span>{t('journey.studio.exportOpen')}</span>
</button>
</div>
</div>
{/*
The sheets, rendered where nobody can see them.
Off screen rather than `display: none`: a hidden subtree lays nothing
out, and these are measured in millimetres by the same CSS that will
print them — a book built from an unlaid-out tree is a book of empty
boxes. `aria-hidden` keeps the whole thing out of the reading order.
*/}
{building && (
<div
ref={stage}
aria-hidden="true"
style={{ position: 'fixed', left: '-20000mm', top: 0, pointerEvents: 'none' }}
>
<BookSheetsView doc={doc} mode={mode} marks={marks} />
</div>
)}
</div>
)
}
const round1 = (n: number) => Math.round(n * 10) / 10
function Choice<T extends string>({
label, options, value, onPick,
}: {
label: string
options: { id: T; icon: typeof FileText; name: string; hint: string }[]
value: T
onPick: (id: T) => void
}) {
return (
<div className="st-ex-field">
<span className="st-ex-label">{label}</span>
{options.map(opt => (
<button
key={opt.id}
className={`st-ex-opt ${value === opt.id ? 'is-on' : ''}`}
onClick={() => onPick(opt.id)}
aria-pressed={value === opt.id}
>
<opt.icon size={15} />
<span className="st-ex-text">
<span className="st-ex-name">{opt.name}</span>
<span className="st-ex-hint">{opt.hint}</span>
</span>
</button>
))}
</div>
)
}
@@ -0,0 +1,515 @@
import type { BookElement, BookPageNumbers, BookPageSetup, BookShapeId, JourneyStats } from '@trek/shared'
import {
AlignCenter, AlignJustify, AlignLeft, AlignRight, ArrowDown, ArrowUp,
ChevronsDown, ChevronsUp, Lock, Trash2, Unlock,
} from 'lucide-react'
import { useRef } from 'react'
import { useStudioStore } from '../../store/studioStore'
import { photoSrc } from './bookRender'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
import { FRAME_SHAPES, SHAPE_GROUPS } from './shapes'
import { ShapeGlyph } from './StudioElementsPanel'
import { TravelInspector } from './StudioTravelInspector'
import { Swatches } from './StudioSwatches'
/**
* Properties of whatever is selected.
*
* Deliberately narrow: it shows what the selected thing actually has, and
* nothing else. A panel that lists every property of every element type with
* most of them greyed out reads as a settings dialog, not as a tool.
*/
export function StudioInspector({
spreadIndex,
page,
stats,
setPageNumbers,
t,
locale,
}: {
spreadIndex: number
page: BookPageSetup
setPageNumbers: (patch: Partial<BookPageNumbers>) => void
/** Live journey figures, so a travel element can be brought up to date. */
stats: JourneyStats | null
t: (k: string) => string
locale: string
}) {
const doc = useStudioStore(s => s.doc)
const selection = useStudioStore(s => s.selection)
const update = useStudioStore(s => s.updateElement)
const commit = useStudioStore(s => s.commit)
const raise = useStudioStore(s => s.raise)
const removeElements = useStudioStore(s => s.removeElements)
const spread = doc?.spreads[spreadIndex]
const sel = spread?.elements.filter(e => selection.includes(e.id)) ?? []
if (!sel.length) {
/*
* Nothing selected, so this is where the *book's* own settings belong.
* The panel was showing one line of hint text and nothing else, and page
* numbers have nowhere else to go: they are not a property of any element,
* because the number a page carries depends on where its spread sits.
*/
const folios = page.pageNumbers
return (
<aside className="st-panel st-inspector">
<div className="st-panel-head"><span>{t('journey.studio.document')}</span></div>
<div className="st-panel-scroll">
<Section label={t('journey.studio.pageNumbers')}>
<div className="st-row">
<button
className={`st-chip ${folios.show ? 'is-on' : ''}`}
onClick={() => setPageNumbers({ show: !folios.show })}
>
{t(folios.show ? 'journey.studio.pageNumbersOn' : 'journey.studio.pageNumbersOff')}
</button>
</div>
{folios.show && (
<>
<div className="st-row" style={{ marginTop: 8 }}>
{(['outer', 'inner', 'centre'] as const).map(pos => (
<button
key={pos}
className={`st-chip ${folios.position === pos ? 'is-on' : ''}`}
onClick={() => setPageNumbers({ position: pos })}
>
{t(`journey.studio.folio.${pos}`)}
</button>
))}
</div>
<div className="st-grid2" style={{ marginTop: 8 }}>
<Num
label={t('journey.studio.folioStart')}
value={folios.startAt}
min={0}
max={9999}
step={1}
onChange={v => setPageNumbers({ startAt: Math.round(v) })}
/>
<Num
label="pt"
value={folios.size}
min={4}
max={48}
step={0.5}
onChange={v => setPageNumbers({ size: v })}
/>
</div>
<div style={{ marginTop: 8 }}>
<Num
label={t('journey.studio.folioMargin')}
value={folios.margin}
min={0}
max={60}
step={0.5}
onChange={v => setPageNumbers({ margin: v })}
/>
</div>
{/*
Automatic first, and picking a colour is what turns it off —
no separate switch to find and no state where a chosen colour
is quietly ignored.
*/}
<div className="st-row" style={{ marginTop: 8 }}>
<button
className={`st-chip ${folios.autoColor ? 'is-on' : ''}`}
onClick={() => setPageNumbers({ autoColor: true })}
aria-pressed={folios.autoColor}
>
{t('journey.studio.folioAuto')}
</button>
</div>
<div style={{ marginTop: 8, opacity: folios.autoColor ? 0.45 : 1 }}>
<Swatches
value={folios.color}
onPick={c => setPageNumbers({ color: c, autoColor: false })}
/>
</div>
</>
)}
</Section>
<p className="st-hint">{t('journey.studio.inspectorEmpty')}</p>
</div>
</aside>
)
}
const el = sel[0]
const one = sel.length === 1
// A property edit is one undo step on its own; only drags open a gesture.
const set = (patch: Partial<BookElement>) => commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => (i !== spreadIndex ? sp : {
...sp,
elements: sp.elements.map(e => (selection.includes(e.id) ? ({ ...e, ...patch } as BookElement) : e)),
})),
}))
const live = (patch: Partial<BookElement>) => update(spreadIndex, el.id, patch)
return (
<aside className="st-panel st-inspector">
<div className="st-panel-head">
<span>{one ? t(`journey.studio.kind.${el.kind}`) : t('journey.studio.multiple')}</span>
<span style={{ fontVariantNumeric: 'tabular-nums' }}>{sel.length > 1 ? sel.length : ''}</span>
</div>
<div className="st-panel-scroll">
{one && (
<Section label={t('journey.studio.position')}>
<div className="st-grid2">
<Num label="X" value={el.frame.x} onChange={v => set({ frame: { ...el.frame, x: v } })} />
<Num label="Y" value={el.frame.y} onChange={v => set({ frame: { ...el.frame, y: v } })} />
<Num label={t('journey.studio.width')} value={el.frame.w} min={4} onChange={v => set({ frame: { ...el.frame, w: v } })} />
<Num label={t('journey.studio.height')} value={el.frame.h} min={4} onChange={v => set({ frame: { ...el.frame, h: v } })} />
</div>
</Section>
)}
{el.kind === 'text' && (
<>
<Section label={t('journey.studio.text')}>
<textarea
className="st-input st-textarea"
value={el.text}
rows={5}
onChange={e => live({ text: e.target.value, overridden: true })}
onBlur={e => set({ text: e.target.value, overridden: true })}
/>
{el.binding && !el.overridden && (
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.boundHint')}</p>
)}
</Section>
<Section label={t('journey.studio.typography')}>
{/*
Each family set in itself, because a list of names tells you
nothing about a typeface — and grouped, because "which serif"
is a different question from "serif or sans".
*/}
<div className="st-fonts">
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
onClick={() => set({
font: id,
// A family that does not ship this weight would render a
// synthesised bold — a smeared regular in print — so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, el.weight) as typeof el.weight,
})}
title={font.name}
>
{font.name}
</button>
)
})}
</div>
<div className="st-grid2" style={{ marginTop: 8 }}>
<Num label="pt" value={el.size} min={4} max={200} step={0.5} onChange={v => set({ size: v })} />
<Num label={t('journey.studio.leading')} value={el.leading} min={0.7} max={3} step={0.05} onChange={v => set({ leading: v })} />
</div>
<div className="st-row" style={{ marginTop: 8 }}>
{([400, 500, 600, 700] as const).map(w => (
<button
key={w}
className={`st-chip ${el.weight === w ? 'is-on' : ''}`}
disabled={!hasWeight(el.font, w)}
title={hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing')}
onClick={() => set({ weight: w })}
>
{w}
</button>
))}
</div>
<div className="st-row" style={{ marginTop: 8 }}>
{([['left', AlignLeft], ['center', AlignCenter], ['right', AlignRight], ['justify', AlignJustify]] as const).map(([a, Icon]) => (
<button key={a} className={`st-chip is-icon ${el.align === a ? 'is-on' : ''}`} onClick={() => set({ align: a })} aria-label={a}>
<Icon size={14} />
</button>
))}
</div>
</Section>
<Section label={t('journey.studio.colour')}>
<Swatches value={el.color} onPick={c => set({ color: c })} />
</Section>
</>
)}
{el.kind === 'photo' && (
<>
<Section label={t('journey.studio.crop')}>
<div className="st-row">
{(['cover', 'contain'] as const).map(f => (
<button key={f} className={`st-chip ${el.fit === f ? 'is-on' : ''}`} onClick={() => set({ fit: f })}>
{t(`journey.studio.fit.${f}`)}
</button>
))}
</div>
<FocalPad
photoId={el.photoId}
x={el.focalX}
y={el.focalY}
onDrag={(x, y) => live({ focalX: x, focalY: y })}
onCommit={(x, y) => set({ focalX: x, focalY: y })}
hint={t('journey.studio.focalHint')}
/>
</Section>
<Section label={t('journey.studio.look')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
{(['none', 'bw', 'warm', 'cool', 'fade', 'contrast'] as const).map(f => (
<button key={f} className={`st-chip ${el.filter === f ? 'is-on' : ''}`} onClick={() => set({ filter: f })}>
{t(`journey.studio.filter.${f}`)}
</button>
))}
</div>
<div style={{ marginTop: 8 }}>
{/* A corner radius and a mask are the same idea at different
resolutions, so the radius steps aside once a shape is cut. */}
<Num
label={t('journey.studio.radius')}
value={el.radius}
min={0}
max={60}
step={0.5}
onChange={v => set({ radius: v })}
/>
</div>
</Section>
<Section label={t('journey.studio.frameStyle')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
{([
['none', 'plainFrame'],
['polaroid', 'polaroidFrame'],
['white', 'whiteFrame'],
['shadow', 'shadowFrame'],
['film', 'filmFrame'],
['tape', 'tapeFrame'],
] as const).map(([style, key]) => (
<button
key={style}
className={`st-chip ${el.frameStyle === style ? 'is-on' : ''}`}
onClick={() => set({ frameStyle: style })}
>
{t(`journey.studio.${key}`)}
</button>
))}
</div>
</Section>
<Section label={t('journey.studio.mask')}>
<div className="st-mini-shapes">
<button
className={`st-mini-shape ${el.mask ? '' : 'is-on'}`}
onClick={() => set({ mask: null })}
title={t('journey.studio.maskNone')}
>
<span className="st-mini-none" />
</button>
{FRAME_SHAPES.filter(sh => sh !== 'rect').map(sh => (
<button
key={sh}
className={`st-mini-shape ${el.mask === sh ? 'is-on' : ''}`}
onClick={() => set({ mask: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
</Section>
</>
)}
{el.kind === 'shape' && (
<>
<Section label={t('journey.studio.shape')}>
{/* Every shape, not just the two the element started as: swapping
a placed shape keeps its position, size and colour, which
deleting and re-adding would not. */}
<div className="st-mini-shapes">
{SHAPE_GROUPS.flatMap(g => g.shapes).map((sh: BookShapeId) => (
<button
key={sh}
className={`st-mini-shape ${el.shape === sh ? 'is-on' : ''}`}
onClick={() => set({ shape: sh })}
aria-label={sh}
>
<ShapeGlyph shape={sh} />
</button>
))}
</div>
<div style={{ marginTop: 8 }}>
<Swatches value={el.fill ?? '#111111'} onPick={c => set({ fill: c })} />
</div>
</Section>
<Section label={t('journey.studio.strokeStyle')}>
<div className="st-row">
{([
['solid', 'strokeSolid'],
['dashed', 'strokeDashed'],
['dotted', 'strokeDotted'],
] as const).map(([style, key]) => (
<button
key={style}
className={`st-chip ${el.strokeStyle === style ? 'is-on' : ''}`}
onClick={() => set({
strokeStyle: style,
// A stroke style with no stroke is invisible, so asking
// for dashes turns the outline on rather than doing
// nothing and looking broken.
...(el.stroke ? {} : { stroke: el.fill ?? '#141414', strokeWidth: el.strokeWidth || 0.5 }),
})}
>
{t(`journey.studio.${key}`)}
</button>
))}
</div>
<div style={{ marginTop: 8 }}>
<Num
label={t('journey.studio.strokeStyle')}
value={el.strokeWidth}
min={0}
max={20}
step={0.1}
onChange={v => set({ strokeWidth: v, ...(v > 0 && !el.stroke ? { stroke: '#141414' } : {}) })}
/>
</div>
</Section>
</>
)}
<TravelInspector el={el} stats={stats} set={set} t={t} locale={locale} Section={Section} />
<Section label={t('journey.studio.arrange')}>
<div className="st-row">
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'front')} title={t('journey.studio.toFront')}>
<ChevronsUp size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'up')} title={t('journey.studio.forward')}>
<ArrowUp size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'down')} title={t('journey.studio.backward')}>
<ArrowDown size={14} />
</button>
<button className="st-chip is-icon" onClick={() => raise(spreadIndex, el.id, 'back')} title={t('journey.studio.toBack')}>
<ChevronsDown size={14} />
</button>
</div>
<div className="st-row" style={{ marginTop: 8 }}>
<button className="st-chip" onClick={() => set({ locked: !el.locked })}>
{el.locked ? <Unlock size={13} /> : <Lock size={13} />}
{t(el.locked ? 'journey.studio.unlock' : 'journey.studio.lock')}
</button>
<button className="st-chip is-danger" onClick={() => removeElements(spreadIndex, selection)}>
<Trash2 size={13} />
{t('journey.studio.delete')}
</button>
</div>
</Section>
<p className="st-hint" style={{ paddingTop: 4 }}>
{t('journey.studio.pageHint')} {page.pageWidth} × {page.pageHeight} mm
</p>
</div>
</aside>
)
}
function Section({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="st-section">
<div className="st-section-label">{label}</div>
{children}
</div>
)
}
function Num({
label, value, onChange, min, max, step = 0.5,
}: { label: string; value: number; onChange: (v: number) => void; min?: number; max?: number; step?: number }) {
return (
<label className="st-field">
<span>{label}</span>
<input
className="st-input"
type="number"
value={Math.round(value * 100) / 100}
step={step}
min={min}
max={max}
onChange={e => {
const v = Number(e.target.value)
if (Number.isFinite(v)) onChange(v)
}}
/>
</label>
)
}
/**
* Where the picture is anchored inside its frame.
*
* Two sliders were the first draft and they were the wrong instrument: a focal
* point is one position in a plane, not two unrelated numbers, and nobody can
* predict what "X 0.62" does to a photograph. Dragging the point over the actual
* image answers the question by showing it.
*/
function FocalPad({
photoId, x, y, onDrag, onCommit, hint,
}: {
photoId: number
x: number
y: number
onDrag: (x: number, y: number) => void
onCommit: (x: number, y: number) => void
hint: string
}) {
const box = useRef<HTMLDivElement>(null)
const last = useRef({ x, y })
const from = (e: React.PointerEvent) => {
const r = box.current!.getBoundingClientRect()
const nx = Math.min(1, Math.max(0, (e.clientX - r.left) / r.width))
const ny = Math.min(1, Math.max(0, (e.clientY - r.top) / r.height))
last.current = { x: nx, y: ny }
return last.current
}
return (
<div className="st-focal-wrap">
<div
className="st-focal"
ref={box}
onPointerDown={e => {
(e.target as Element).setPointerCapture(e.pointerId)
const p = from(e)
onDrag(p.x, p.y)
}}
onPointerMove={e => {
if (!e.currentTarget.hasPointerCapture?.(e.pointerId)) return
const p = from(e)
onDrag(p.x, p.y)
}}
onPointerUp={() => onCommit(last.current.x, last.current.y)}
>
<img src={photoSrc(photoId, false)} alt="" draggable={false} />
<span className="st-focal-dot" style={{ left: `${x * 100}%`, top: `${y * 100}%` }} />
</div>
<p className="st-hint" style={{ padding: '6px 0 0' }}>{hint}</p>
</div>
)
}
@@ -0,0 +1,15 @@
/**
* The bar at the top of a Studio panel: what it is, and how many.
*
* Its own file so the three panels can share it without any of them having to
* import the others — `StudioSidebar` owns the rail, and a panel that reached
* back into it for a header would make the sidebar impossible to split.
*/
export function PanelHead({ label, count }: { label: string; count?: number }) {
return (
<div className="st-panel-head">
<span>{label}</span>
{count != null && <span style={{ fontVariantNumeric: 'tabular-nums' }}>{count}</span>}
</div>
)
}
@@ -0,0 +1,437 @@
import { useEffect, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { useNavigate } from 'react-router'
import {
ArrowLeft, BookOpen, Check, ChevronDown, Download, Maximize2, Minus, Plus,
Redo2, Sparkles, Undo2,
} from 'lucide-react'
import { useJourneyStudio } from '../../pages/journeyStudio/useJourneyStudio'
import { PAGE_PRESET_ORDER, PAGE_PRESETS } from './pagePresets'
import { StudioSidebar } from './StudioSidebar'
import { StudioCanvas } from './StudioCanvas'
import { StudioInspector } from './StudioInspector'
import { StudioWordmark } from './StudioWordmark'
import { SaveIndicator } from './SaveIndicator'
import { downloadSpread } from './spreadFile'
import { StudioExport } from './StudioExport'
import { PeerBadges } from './PeerBadges'
import { TrimField } from './TrimField'
import '../../styles/dashboard.css'
import '../../styles/studio.css'
/**
* The Studio shell: top bar, page rail, workbench, inspector.
*
* It is a child route of the journey and portals itself over it, so the journey
* stays mounted underneath and shows through the blurred 16px margin. That
* margin is the whole point — you are meant to see that you never left.
*
* `.trek-dash` on the root is not decoration: every colour, radius and shadow in
* studio.css resolves against those tokens, so Studio follows the user's theme
* and accent without a second palette to keep in sync.
*/
export default function StudioShell() {
const s = useJourneyStudio()
const navigate = useNavigate()
const [bookView, setBookView] = useState(true)
const [exporting, setExporting] = useState(false)
if (s.isMobile) {
return createPortal(
<div className="st-phone trek-dash">
<BookOpen size={30} strokeWidth={1.5} style={{ color: 'var(--ink-3)' }} />
<div style={{ fontSize: 17, fontWeight: 600, letterSpacing: '-0.02em' }}>
{s.t('journey.studio.desktopOnly')}
</div>
<div style={{ fontSize: 13.5, color: 'var(--ink-3)', maxWidth: 320, lineHeight: 1.55 }}>
{s.t('journey.studio.desktopOnlyHint')}
</div>
<button
onClick={() => navigate(s.backTo, { replace: true })}
className="st-tool is-primary"
style={{ marginTop: 6 }}
>
{s.t('journey.studio.backToJourney')}
</button>
</div>,
document.body,
)
}
const originStyle = s.origin
? ({ '--st-ox': `${s.origin.x}px`, '--st-oy': `${s.origin.y}px` } as React.CSSProperties)
: undefined
return createPortal(
<>
<div
className={`st-scrim ${s.closing ? 'st-scrim-exit' : 'st-scrim-enter'}`}
onClick={s.close}
aria-hidden
/>
<div
className={`st-root trek-dash ${s.closing ? 'st-exit' : 'st-enter'}`}
style={originStyle}
role="dialog"
aria-modal="true"
aria-label={s.t('journey.studio.title')}
>
<StudioBar s={s} bookView={bookView} setBookView={setBookView} onExport={() => setExporting(true)} />
<div className="st-body">
<StudioSidebar
page={s.page}
pxPerMm={s.pxPerMm}
bookView={bookView}
source={s.source}
stats={s.stats}
path={s.path}
t={s.t}
locale={s.locale}
/>
<Workbench s={s} bookView={bookView} />
<StudioInspector
spreadIndex={s.activeSpread}
page={s.page}
stats={s.stats}
setPageNumbers={s.setPageNumbers}
t={s.t}
locale={s.locale}
/>
</div>
{exporting && s.doc && (
<StudioExport
doc={s.doc}
title={s.doc.title || s.journey?.title || ''}
t={s.t}
onClose={() => setExporting(false)}
/>
)}
</div>
</>,
document.body,
)
}
type Studio = ReturnType<typeof useJourneyStudio>
function StudioBar({
s, bookView, setBookView, onExport,
}: { s: Studio; bookView: boolean; setBookView: (v: boolean) => void; onExport: () => void }) {
return (
<div className="st-bar">
<button onClick={s.close} className="st-back" title={s.t('journey.studio.backToJourney')}>
<ArrowLeft size={16} />
{s.coverUrl
? <img src={s.coverUrl} alt="" className="st-cover" />
: <span className="st-cover" />}
<span className="st-back-label">{s.journey?.title || s.t('journey.title')}</span>
</button>
<div className="st-sep" />
{/*
The lockup, not a name field. The book is the journey's, and it was
already titled after it — a second place to type the same name is a
field whose only job is to be left alone. What the bar is short of is
somewhere to say what this thing *is*.
*/}
<div className="st-brand">
<StudioWordmark style={{ height: 26 }} />
<span className="st-beta">{s.t('journey.studio.beta')}</span>
</div>
<PeerBadges peers={s.peers} t={s.t} />
<SaveIndicator
state={s.saveState}
t={s.t}
onAcceptTheirs={current => s.loadDoc(s.acceptTheirs(current))}
onKeepMine={s.keepMine}
onRetry={() => { void s.saveNow() }}
/>
<div className="st-bar-group">
<button
className={`st-tool ${bookView ? 'is-on' : ''}`}
onClick={() => setBookView(!bookView)}
title={s.t('journey.studio.bookView')}
>
<BookOpen size={14} />
<span className="st-tool-label">{s.t('journey.studio.bookView')}</span>
</button>
<div className="st-sep" />
<FormatPicker s={s} />
<div className="st-sep" />
<button
className="st-tool is-icon"
disabled={!s.canUndo}
onClick={s.undo}
title={s.t('journey.studio.undo')}
aria-label={s.t('journey.studio.undo')}
>
<Undo2 size={15} />
</button>
<button
className="st-tool is-icon"
disabled={!s.canRedo}
onClick={s.redo}
title={s.t('journey.studio.redo')}
aria-label={s.t('journey.studio.redo')}
>
<Redo2 size={15} />
</button>
<div className="st-sep" />
<AutoLayoutButton s={s} />
<button
className="st-tool is-primary"
onClick={onExport}
disabled={!s.doc}
title={s.t('journey.studio.export')}
>
<Download size={14} />
<span className="st-tool-label">{s.t('journey.studio.export')}</span>
</button>
</div>
</div>
)
}
/**
* Lay out again — this spread, or the whole book.
*
* A split button rather than two: they are the same action at two scopes, and
* the spread is the one you want nine times out of ten. Rebuilding the whole
* book is behind the chevron, where it cannot be hit by accident, and both are
* ordinary undo steps.
*/
function AutoLayoutButton({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
}
document.addEventListener('pointerdown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
return (
<div className="st-picker" ref={box}>
<button
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="menu"
aria-expanded={open}
title={s.t('journey.studio.autoLayout')}
>
<Sparkles size={14} />
<span className="st-tool-label">{s.t('journey.studio.autoLayout')}</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="menu">
<button
className="st-menu-item"
role="menuitem"
disabled={!s.canRelayoutSpread}
onClick={() => { s.relayoutCurrentSpread(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutSpread')}</span>
<span className="st-menu-dim">
{s.t(s.canRelayoutSpread ? 'journey.studio.relayoutSpreadHint' : 'journey.studio.relayoutSpreadNone')}
</span>
</span>
</button>
<button
className="st-menu-item"
role="menuitem"
onClick={() => { s.relayoutBook(); setOpen(false) }}
>
<span className="st-menu-text">
<span className="st-menu-name">{s.t('journey.studio.relayoutBook')}</span>
<span className="st-menu-dim">{s.t('journey.studio.relayoutBookHint')}</span>
</span>
</button>
</div>
)}
</div>
)
}
/**
* The format picker.
*
* A native <select> was the first draft and it was wrong: the browser draws the
* list in its own chrome, so it ignores the theme entirely and drops a list that
* runs off the bottom of the screen. This is a small popover instead — themed,
* anchored under the bar, and it shows each format's proportions rather than
* only naming them.
*/
function FormatPicker({ s }: { s: Studio }) {
const [open, setOpen] = useState(false)
const box = useRef<HTMLDivElement>(null)
useEffect(() => {
if (!open) return
const onDown = (e: PointerEvent) => {
if (!box.current?.contains(e.target as Node)) setOpen(false)
}
const onKey = (e: KeyboardEvent) => {
// Swallow it, or the shell's Escape handler would close Studio itself.
if (e.key === 'Escape') { e.stopPropagation(); setOpen(false) }
}
document.addEventListener('pointerdown', onDown)
document.addEventListener('keydown', onKey, true)
return () => {
document.removeEventListener('pointerdown', onDown)
document.removeEventListener('keydown', onKey, true)
}
}, [open])
const active = PAGE_PRESETS[s.preset] ?? PAGE_PRESETS['square-210']
return (
<div className="st-picker" ref={box}>
<button
className="st-tool"
onClick={() => setOpen(o => !o)}
aria-haspopup="listbox"
aria-expanded={open}
aria-label={s.t('journey.studio.format')}
>
<span className="st-tool-label">
{s.preset === 'custom'
? `${Math.round(s.page.pageWidth)} × ${Math.round(s.page.pageHeight)} mm`
: s.t(active.labelKey)}
</span>
<ChevronDown size={13} style={{ opacity: .5 }} />
</button>
{open && (
<div className="st-menu" role="listbox">
{PAGE_PRESET_ORDER.map(id => {
const p = PAGE_PRESETS[id]
const on = id === s.preset
return (
<button
key={id}
role="option"
aria-selected={on}
className={`st-menu-item ${on ? 'is-active' : ''}`}
onClick={() => { s.setPreset(id); setOpen(false) }}
>
<span className="st-menu-tile">
<span
className="st-menu-shape"
style={
p.pageWidthMm >= p.pageHeightMm
? { width: 24, height: Math.round(24 * p.pageHeightMm / p.pageWidthMm) }
: { height: 24, width: Math.round(24 * p.pageWidthMm / p.pageHeightMm) }
}
/>
</span>
<span className="st-menu-text">
<span className="st-menu-name">{s.t(p.labelKey)}</span>
<span className="st-menu-dim">{p.pageWidthMm} × {p.pageHeightMm} mm</span>
</span>
{on && <Check size={14} />}
</button>
)
})}
{/*
The two fields live under the list rather than behind a "custom"
entry that opens a second popover: picking the custom row and then
typing into the fields it reveals is two steps for one decision, and
typing into them *is* picking custom — the hook switches the preset
for you.
*/}
<div className="st-menu-sep" />
<div className="st-menu-custom">
<TrimField
label={s.t('journey.studio.width')}
value={s.page.pageWidth}
onCommit={v => s.setPageSize('w', v)}
/>
<span className="st-menu-times">×</span>
<TrimField
label={s.t('journey.studio.height')}
value={s.page.pageHeight}
onCommit={v => s.setPageSize('h', v)}
/>
<span className="st-menu-unit">mm</span>
</div>
</div>
)}
</div>
)
}
function Workbench({ s, bookView }: { s: Studio; bookView: boolean }) {
return (
<div className="st-work-wrap">
<div className="st-work" ref={s.workRef}>
<StudioCanvas
spread={s.spread}
spreadIndex={s.activeSpread}
page={s.page}
zoom={s.zoom}
pxPerMm={s.pxPerMm}
bookView={bookView}
dropLabel={s.t('journey.studio.dropPhotoHere')}
cursors={s.cursors}
onCursor={(x, y) => s.moveCursor(s.activeSpread, x, y)}
/>
</div>
<div className="st-zoom">
<button onClick={() => s.stepZoom(-1)} disabled={!s.canZoomOut} aria-label={s.t('journey.studio.zoomOut')}>
<Minus size={15} />
</button>
<span className="st-zoom-value">{s.zoomPercent}%</span>
<button onClick={() => s.stepZoom(1)} disabled={!s.canZoomIn} aria-label={s.t('journey.studio.zoomIn')}>
<Plus size={15} />
</button>
<button onClick={s.zoomToFit} aria-label={s.t('journey.studio.zoomFit')} title={s.t('journey.studio.zoomFit')}>
<Maximize2 size={14} />
</button>
{/*
The page, as a file.
Here rather than in a menu because it belongs to the spread on screen
— the zoom bar is the only chrome that is about *this* page rather
than about the book. What comes out is the design without the
photographs, which is the thing worth passing on.
*/}
<span className="st-zoom-sep" />
<button
onClick={() => s.spread && downloadSpread(s.spread, s.page, s.journey?.title || 'spread')}
disabled={!s.spread}
aria-label={s.t('journey.studio.downloadSpread')}
title={s.t('journey.studio.downloadSpreadHint')}
>
<Download size={14} />
</button>
</div>
</div>
)
}
@@ -0,0 +1,610 @@
import { useMemo, useRef, useState } from 'react'
import {
ChevronDown, ChevronUp, Compass, Copy, Files, ImageIcon, LayoutTemplate, Plus,
Search, Shapes, Trash2, Upload, X,
} from 'lucide-react'
import type { BookElement, BookPageSetup, JourneyStats } from '@trek/shared'
import { useElementSize } from '../../hooks/useElementSize'
import { useStudioStore } from '../../store/studioStore'
import { formatDate } from '../../utils/formatters'
import { SpreadFold, SpreadView } from './SpreadView'
import { photoSrc } from './bookRender'
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from './templates'
import { MOOD_CONFIG, WEATHER_CONFIG } from '../../pages/journeyDetail/JourneyDetailPage.constants'
import { PanelHead as Head } from './StudioPanelHead'
import { StudioElementsPanel } from './StudioElementsPanel'
import { StudioTravelPanel } from './StudioTravelPanel'
import { MAX_SPREAD_FILE_BYTES, importSpread } from './spreadFile'
/**
* The left side of Studio: a narrow rail of sections and one wide panel showing
* whichever is open.
*
* The shape is Canva's and it is the right one here for a plain reason — a book
* has four completely different kinds of thing you reach for (which page, what
* from the journey, what to draw, what arrangement), and putting them behind
* tabs in a single 200px column would make each of them cramped. An icon rail
* costs 52px and gives the panel the room a photo browser actually needs.
*/
type Section = 'pages' | 'content' | 'elements' | 'travel' | 'templates'
export interface JourneySource {
entries: {
id: number
title: string | null
story: string | null
location: string | null
date: string | null
mood: string | null
weather: string | null
pros: string[]
cons: string[]
}[]
photos: { photoId: number; caption?: string | null }[]
/** photoId to the words of the entry it belongs to, lower-cased. */
photoEntries: Record<number, string>
}
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/**
* How wide a page preview may be drawn.
*
* Page thumbnails and layout cards are drawn at a `scale()` rather than laid
* out by CSS — the sheet inside them is sized in millimetres — so they need an
* actual pixel width, and it is **measured** rather than derived.
*
* Deriving it was tried twice and was wrong twice. The panel width, the scroll
* padding and the button's own border and padding are all knowable from
* studio.css; the scrollbar is not. It appears only once the list is long
* enough, takes a width that depends on the platform, and the arithmetic that
* ignored it produced a card overhanging its own selection outline. Measuring
* the row the cards sit in gets all four contributions at once and cannot drift
* when any of them changes.
*/
const MIN_PREVIEW_W = 120
/** The button's border and padding, which the row's width still includes. */
const THUMB_CHROME = (2 + 3) * 2
export function StudioSidebar({
page, pxPerMm, bookView, source, stats, path, t, locale,
}: {
page: BookPageSetup
pxPerMm: number
bookView: boolean
source: JourneySource
/** What the journey adds up to, for the travel elements. Null while loading or on failure. */
stats: JourneyStats | null
/** The roads the trip took, for a map that draws them. Empty when it has none. */
path: [number, number][][]
t: (k: string) => string
locale: string
}) {
const [section, setSection] = useState<Section>('pages')
const SECTIONS: { id: Section; icon: typeof Files; labelKey: string }[] = [
{ id: 'pages', icon: Files, labelKey: 'journey.studio.pages' },
{ id: 'content', icon: ImageIcon, labelKey: 'journey.studio.content' },
{ id: 'elements', icon: Shapes, labelKey: 'journey.studio.elements' },
{ id: 'travel', icon: Compass, labelKey: 'journey.studio.travel' },
{ id: 'templates', icon: LayoutTemplate, labelKey: 'journey.studio.templates' },
]
return (
<>
<nav className="st-rail" aria-label={t('journey.studio.sections')}>
{SECTIONS.map(sec => (
<button
key={sec.id}
className={`st-rail-btn ${section === sec.id ? 'is-on' : ''}`}
onClick={() => setSection(sec.id)}
title={t(sec.labelKey)}
aria-label={t(sec.labelKey)}
>
<sec.icon size={18} strokeWidth={1.7} />
<span>{t(sec.labelKey)}</span>
</button>
))}
</nav>
<aside className="st-panel st-side">
{section === 'pages' && <PagesPanel page={page} pxPerMm={pxPerMm} bookView={bookView} t={t} />}
{section === 'content' && <ContentPanel source={source} page={page} t={t} locale={locale} />}
{section === 'elements' && <StudioElementsPanel page={page} t={t} />}
{section === 'travel' && <StudioTravelPanel page={page} stats={stats} path={path} t={t} locale={locale} />}
{section === 'templates' && <TemplatesPanel page={page} pxPerMm={pxPerMm} t={t} onOpenContent={() => setSection('content')} />}
</aside>
</>
)
}
function PagesPanel({
page, pxPerMm, bookView, t,
}: { page: BookPageSetup; pxPerMm: number; bookView: boolean; t: (k: string) => string }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
const setActive = useStudioStore(s => s.setActiveSpread)
const addSpread = useStudioStore(s => s.addSpread)
const insertSpread = useStudioStore(s => s.insertSpread)
const duplicateSpread = useStudioStore(s => s.duplicateSpread)
const removeSpread = useStudioStore(s => s.removeSpread)
const moveSpread = useStudioStore(s => s.moveSpread)
const canEditSpread = useStudioStore(s => s.canEditSpread)
const spreads = doc?.spreads ?? []
const list = useElementSize<HTMLDivElement>()
const THUMB_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
/** Where a new spread would land: after the last inner one. */
const lastInner = spreads.reduce((last, sp, i) => (sp.role === 'inner' ? i : last), -1)
const fileInput = useRef<HTMLInputElement>(null)
const [importError, setImportError] = useState<string | null>(null)
return (
<>
<Head label={t('journey.studio.pages')} count={spreads.length} />
<div className="st-panel-scroll">
<div className="st-thumbs" ref={list.ref}>
{spreads.map((sp, i) => {
const single = sp.role !== 'inner'
const wMm = single ? page.pageWidth : page.pageWidth * 2
// The same component at a smaller scale, not a second drawing of the
// page — a spread can never look different here than on the sheet.
const scale = THUMB_W / (wMm * pxPerMm)
const editable = canEditSpread(i)
return (
<div className="st-thumb-row" key={sp.id}>
<button
className={`st-thumb ${i === active ? 'is-active' : ''}`}
onClick={() => setActive(i)}
>
<div className="st-thumb-sheet" style={{ width: THUMB_W, height: page.pageHeight * pxPerMm * scale }}>
<div
style={{
position: 'absolute', left: 0, top: 0,
width: `${wMm}mm`, height: `${page.pageHeight}mm`,
transform: `scale(${scale})`, transformOrigin: 'top left',
}}
>
<SpreadView spread={sp} page={page} spreadIndex={i} />
</div>
{bookView && !single && <SpreadFold page={page} scaled={pxPerMm * scale} />}
</div>
<span className="st-thumb-label">
{sp.role === 'cover' ? t('journey.studio.cover')
: sp.role === 'back' ? t('journey.studio.backCover')
: `${i * 2} – ${i * 2 + 1}`}
</span>
</button>
{/*
The actions sit on the thumbnail rather than in a menu bar of
their own: what you are acting on is the page you are pointing
at, and a toolbar somewhere else would need you to select
first and act second. The cover and the back cover have no
actions — a book has exactly one of each, and moving or
deleting them is not a thing anyone means to do.
*/}
{editable && (
<div className="st-thumb-actions">
<button
onClick={() => moveSpread(i, -1)}
disabled={!canEditSpread(i - 1)}
title={t('journey.studio.movePageUp')}
aria-label={t('journey.studio.movePageUp')}
>
<ChevronUp size={13} />
</button>
<button
onClick={() => moveSpread(i, 1)}
disabled={!canEditSpread(i + 1)}
title={t('journey.studio.movePageDown')}
aria-label={t('journey.studio.movePageDown')}
>
<ChevronDown size={13} />
</button>
<button
onClick={() => duplicateSpread(i)}
title={t('journey.studio.duplicatePage')}
aria-label={t('journey.studio.duplicatePage')}
>
<Copy size={12} />
</button>
<button
className="is-danger"
onClick={() => removeSpread(i)}
title={t('journey.studio.deletePage')}
aria-label={t('journey.studio.deletePage')}
>
<Trash2 size={12} />
</button>
</div>
)}
{/* Insert between two spreads, where the new one will go — a
single "add" at the end cannot express "another page here". */}
{editable && (
<button
className="st-thumb-insert"
onClick={() => addSpread(i)}
title={t('journey.studio.addPageAfter')}
aria-label={t('journey.studio.addPageAfter')}
>
<Plus size={12} />
</button>
)}
</div>
)
})}
</div>
{/*
Two ways to get a new page: an empty one, or somebody else's design.
Side by side because they answer the same question — the import is
not a setting buried in a menu, it is the other half of "add".
*/}
<div className="st-add-row">
<button className="st-add-page" onClick={() => addSpread(lastInner)}>
<Plus size={14} />
{t('journey.studio.addPage')}
</button>
<button
className="st-add-page is-import"
onClick={() => fileInput.current?.click()}
title={t('journey.studio.importSpreadHint')}
>
<Upload size={14} />
{t('journey.studio.importSpread')}
</button>
</div>
<input
ref={fileInput}
type="file"
accept=".json,application/json"
hidden
onChange={async e => {
const file = e.target.files?.[0]
// Cleared straight away so choosing the same file twice in a row
// still fires a change event the second time.
e.target.value = ''
if (!file) return
setImportError(null)
// Checked before reading rather than after: the point of a limit on
// a file someone else wrote is not to parse it in the first place.
if (file.size > MAX_SPREAD_FILE_BYTES) { setImportError(t('journey.studio.importSpreadFailed')); return }
try {
const spread = importSpread(JSON.parse(await file.text()), page)
if (!spread) { setImportError(t('journey.studio.importSpreadFailed')); return }
insertSpread(lastInner, spread)
} catch {
setImportError(t('journey.studio.importSpreadFailed'))
}
}}
/>
{importError && <p className="st-add-error">{importError}</p>}
</div>
</>
)
}
/**
* The journey's own material.
*
* This is what separates a book maker from a drawing program: the pictures and
* the words are already written, and the job is putting them on pages. Clicking
* an item drops it on the current spread, centred, at a sensible size.
*/
function ContentPanel({
source, page, t, locale,
}: { source: JourneySource; page: BookPageSetup; t: (k: string) => string; locale: string }) {
const [tab, setTab] = useState<'photos' | 'text'>('photos')
const [query, setQuery] = useState('')
/*
* Filtering, not a search index: a journey holds tens or hundreds of items, and
* a substring match over what is already in memory answers instantly. A photo
* matches on its own caption *and* on the entry it belongs to — most photos
* carry no words at all, so matching only captions would make the box look
* broken on exactly the journeys that need it.
*/
const q = query.trim().toLowerCase()
const entries = q
? source.entries.filter(e =>
[e.title, e.story, e.location, ...e.pros, ...e.cons]
.some(v => v && v.toLowerCase().includes(q)))
: source.entries
const photos = q
? source.photos.filter(p =>
(p.caption && p.caption.toLowerCase().includes(q))
|| (source.photoEntries[p.photoId] || '').includes(q))
: source.photos
const addElement = useStudioStore(s => s.addElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const centre = (w: number, h: number) => {
const W = spread && spread.role !== 'inner' ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
const dropPhoto = (photoId: number) => {
const side = Math.min(page.pageWidth, page.pageHeight) * 0.55
addElement(active, {
id: uid('p'), kind: 'photo', frame: centre(side, side * 0.75),
rotation: 0, opacity: 1, locked: false,
photoId, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none',
} as BookElement)
}
const dropText = (value: string, size: number, weight: 400 | 700, entryId: number, kind: 'entry.title' | 'entry.story' | 'entry.location' | 'entry.date') => {
const w = page.pageWidth - 32
addElement(active, {
id: uid('t'), kind: 'text', frame: centre(w, size * 0.4 + 10),
rotation: 0, opacity: 1, locked: false,
text: value, font: 'sans', size, weight, italic: false,
align: 'left', leading: weight === 700 ? 1.1 : 1.55, tracking: weight === 700 ? -0.02 : 0,
color: '#1a1a1a', binding: { source: kind, entryId }, overridden: false,
} as BookElement)
}
const base = {
rotation: 0, opacity: 1, locked: false,
font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false,
}
/**
* A mood or a weather mark, drawn with the icon the journey page uses.
*
* `code` holds the key rather than the label, so the element carries what the
* entry recorded and the renderer decides how to draw it — which is what lets
* the icon and the palette come from one place.
*/
const dropMark = (variant: 'mood' | 'weather', code: string, label: string) =>
addElement(active, {
...base, id: uid('bd'), kind: 'badge', autoColor: true,
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.062),
variant, text: label, sub: '', code, style: 'plain',
} as BookElement)
/** Both columns of an entry's pros and cons, as one element. */
const dropProsCons = (pros: string[], cons: string[]) => {
const rows = Math.max(pros.length, cons.length, 1)
addElement(active, {
...base, id: uid('li'), kind: 'list',
// Matches what ListView needs for `rows` lines at its largest line size,
// so the element arrives filled rather than with a third of it empty.
frame: centre(page.pageWidth * 0.72, Math.min(page.pageHeight * 0.6, 6 * (0.86 + 1.7 * rows))),
items: [
...pros.map(text => ({ text, tone: 'pro' as const })),
...cons.map(text => ({ text, tone: 'con' as const })),
],
layout: 'columns', showMarks: true,
proLabel: t('journey.editor.pros'),
conLabel: t('journey.editor.cons'),
} as BookElement)
}
return (
<>
<Head label={t('journey.studio.content')} />
<div className="st-search">
<Search size={14} />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder={t('journey.studio.searchContent')}
aria-label={t('journey.studio.searchContent')}
spellCheck={false}
/>
{query && (
<button onClick={() => setQuery('')} aria-label={t('common.clear')}>
<X size={13} />
</button>
)}
</div>
<div className="st-tabs">
<button className={tab === 'photos' ? 'is-on' : ''} onClick={() => setTab('photos')}>
{t('journey.studio.photos')} <em>{photos.length}</em>
</button>
<button className={tab === 'text' ? 'is-on' : ''} onClick={() => setTab('text')}>
{t('journey.studio.entries')} <em>{entries.length}</em>
</button>
</div>
<div className="st-panel-scroll">
{tab === 'photos' ? (
<div className="st-photo-grid">
{photos.map(p => (
<button
key={p.photoId}
className="st-photo-cell"
onClick={() => dropPhoto(p.photoId)}
title={t('journey.studio.addToPage')}
draggable
onDragStart={e => {
// HTML5 drag is the right tool for exactly this shape of
// interaction — one item, from a list, onto a target — and it
// gives us the thumbnail as the drag image for free. The
// canvas uses pointer events instead, because free transform
// needs a live position that a drop event cannot provide.
e.dataTransfer.setData('application/x-trek-photo', String(p.photoId))
e.dataTransfer.effectAllowed = 'copy'
}}
>
<img src={photoSrc(p.photoId, false)} alt="" loading="lazy" draggable={false} />
</button>
))}
{!photos.length && (
<p className="st-hint">{t(q ? 'journey.studio.noMatches' : 'journey.studio.noPhotos')}</p>
)}
</div>
) : (
<div className="st-entries">
{entries.map(e => (
<div key={e.id} className="st-entry">
<div className="st-entry-head">{e.title || e.location || t('journey.studio.untitled')}</div>
{(e.date || e.location) && (
<div className="st-entry-meta">
{/* Two facts, two badges. Joined by a dot they read as one
string and the eye has to parse where the date ends. The
date follows the app's language, like every other date in
TREK — see utils/formatters.ts. */}
{e.date && <span className="st-badge">{formatDate(e.date, locale) ?? e.date}</span>}
{e.location && <span className="st-badge is-quiet">{e.location}</span>}
</div>
)}
<div className="st-row" style={{ marginTop: 7 }}>
{e.title && (
<button className="st-chip" onClick={() => dropText(e.title!, 22, 700, e.id, 'entry.title')}>
{t('journey.studio.addTitle')}
</button>
)}
{e.story && (
<button className="st-chip" onClick={() => dropText(e.story!, 10, 400, e.id, 'entry.story')}>
{t('journey.studio.addStory')}
</button>
)}
{e.location && (
<button className="st-chip" onClick={() => dropText(e.location!, 8, 700, e.id, 'entry.location')}>
{t('journey.studio.addPlace')}
</button>
)}
{/*
What the entry recorded besides its story. Each chip shows
the value rather than the field name — "Sunny" tells you
what you are about to place; "Weather" does not.
*/}
{e.mood && MOOD_CONFIG[e.mood] && (
<button
className="st-chip"
onClick={() => dropMark('mood', e.mood!, t(MOOD_CONFIG[e.mood!].label))}
>
{t(MOOD_CONFIG[e.mood].label)}
</button>
)}
{e.weather && WEATHER_CONFIG[e.weather] && (
<button
className="st-chip"
onClick={() => dropMark('weather', e.weather!, t(WEATHER_CONFIG[e.weather!].label))}
>
{t(WEATHER_CONFIG[e.weather].label)}
</button>
)}
{(e.pros.length > 0 || e.cons.length > 0) && (
<button className="st-chip" onClick={() => dropProsCons(e.pros, e.cons)}>
{t('journey.studio.addProsCons')}
<em>{e.pros.length + e.cons.length}</em>
</button>
)}
</div>
</div>
))}
{!entries.length && <p className="st-hint">{t('journey.studio.noMatches')}</p>}
</div>
)}
</div>
</>
)
}
/**
* Layouts for the current spread.
*
* Each card draws the arrangement itself rather than naming it — "hero + story"
* means nothing until you have seen it, and a diagram is faster to read than a
* label anyway.
*/
function TemplatesPanel({
page, pxPerMm, t, onOpenContent,
}: { page: BookPageSetup; pxPerMm: number; t: (k: string) => string; onOpenContent: () => void }) {
const doc = useStudioStore(s => s.doc)
const active = useStudioStore(s => s.activeSpread)
const commit = useStudioStore(s => s.commit)
const spread = doc?.spreads[active]
const list = useElementSize<HTMLDivElement>()
const CARD_W = Math.max(MIN_PREVIEW_W, list.width - THUMB_CHROME)
const doc2 = doc
const cardIsSingle = (doc2?.spreads[active]?.role ?? 'inner') !== 'inner'
// A cover card is one page wide, not two — scaling it as a spread would draw
// the layout at half size in the left half of the card.
const scale = useMemo(
() => CARD_W / (page.pageWidth * (cardIsSingle ? 1 : 2) * pxPerMm),
[CARD_W, page.pageWidth, pxPerMm, cardIsSingle],
)
if (!spread) return null
const single = spread.role !== 'inner'
/*
* The cover has its own set. It used to have none — the panel said covers
* are designed by hand, which was true and unhelpful: the cover is the page
* people care most about and the one they had no starting point for.
*/
const templates = single ? COVER_TEMPLATES : TEMPLATES
return (
<>
<Head label={t('journey.studio.templates')} count={templates.length} />
<div className="st-panel-scroll">
{(
<div className="st-thumbs" ref={list.ref}>
{templates.map(tpl => {
const slots = tpl.build(page)
return (
<button
key={tpl.id}
className="st-thumb"
onClick={() => {
let empties = 0
commit(d => ({
...d,
spreads: d.spreads.map((sp, i) => {
if (i !== active) return sp
const next = applyTemplate(sp, tpl, page)
empties = next.elements.filter(e => e.kind === 'photo' && e.photoId == null).length
return next
}),
}))
// The layout left frames waiting for a picture — so put the
// pictures within reach instead of making the user go and
// find the panel that holds them.
if (empties > 0) onOpenContent()
}}
>
<div
className="st-thumb-sheet"
style={{ width: CARD_W, height: page.pageHeight * pxPerMm * scale }}
>
{slots.map((slot, i) => (
<span
key={i}
className={`st-tpl-slot is-${slot.kind}`}
style={{
left: slot.frame.x * pxPerMm * scale,
top: slot.frame.y * pxPerMm * scale,
width: slot.frame.w * pxPerMm * scale,
height: Math.max(2, slot.frame.h * pxPerMm * scale),
}}
/>
))}
{!single && (
<span
className="st-tpl-fold"
style={{ left: page.pageWidth * pxPerMm * scale }}
/>
)}
</div>
<span className="st-thumb-label">{t(tpl.labelKey)}</span>
</button>
)
})}
</div>
)}
</div>
</>
)
}
@@ -0,0 +1,37 @@
/**
* The colour picker, shared by everything in the inspector that has a colour.
*
* Its own file because both the plain elements and the travel elements need it,
* and the second copy of a swatch row is how two parts of one panel end up
* offering different palettes.
*
* The palette is print ink rather than app chrome — it does not follow the
* user's theme, because a book is printed once and read on paper, where an
* accent chosen for a dark interface means nothing.
*/
const SWATCHES = [
'#111111', '#ffffff', '#8a8578',
'#c2410c', '#b45309', '#0f766e',
'#1e3a8a', '#9f1239', '#4c1d95',
]
export function Swatches({ value, onPick }: { value: string; onPick: (c: string) => void }) {
return (
<div className="st-swatches">
{SWATCHES.map(c => (
<button
key={c}
className={`st-swatch ${value.toLowerCase() === c ? 'is-on' : ''}`}
style={{ background: c }} // theme-lint-disable — a colour picker shows the colours themselves
onClick={() => onPick(c)}
aria-label={c}
/>
))}
{/* Anything at all, for a book that has to match a cover or a brand. */}
<label className="st-swatch st-swatch-custom">
<input type="color" value={value} onChange={e => onPick(e.target.value)} />
</label>
</div>
)
}
@@ -0,0 +1,543 @@
import type {
BookBadgeElement, BookCountriesElement, BookElement, BookListElement, BookMapElement,
BookMetric, BookStatsElement, JourneyStats,
} from '@trek/shared'
import { BOOK_METRICS } from '@trek/shared'
import { isStale, refreshPatch } from './travelRefresh'
import { Swatches } from './StudioSwatches'
import { BOOK_FONTS, BOOK_FONT_ORDER, hasWeight, nearestWeight } from './bookFonts'
import { useMapSources } from './mapSources'
/**
* Properties of the travel elements.
*
* Its own file because these four are a different kind of thing from a photo or
* a paragraph: what you adjust is *what the element says*, not how a box looks.
* Keeping them here leaves StudioInspector.tsx about the elements that have
* been there since the beginning.
*/
export interface TravelInspectorProps {
el: BookElement
/** Live figures, for the refresh button. Null while loading or on failure. */
stats: JourneyStats | null
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
locale: string
Section: (p: { label: string; children: React.ReactNode }) => React.JSX.Element
}
function Toggle({ on, label, onClick }: { on: boolean; label: string; onClick: () => void }) {
return (
<button className={`st-chip ${on ? 'is-on' : ''}`} onClick={onClick}>
{label}
</button>
)
}
export function TravelInspector({ el, stats, set, t, Section }: TravelInspectorProps) {
if (
el.kind !== 'map' && el.kind !== 'stats' && el.kind !== 'countries'
&& el.kind !== 'badge' && el.kind !== 'list'
) {
return null
}
const stale = isStale(el, stats)
return (
<>
{stale && (
<div className="st-section">
<p className="st-stale">{t('journey.studio.staleHint')}</p>
<button
className="st-chip"
onClick={() => {
if (!stats) return
const patch = refreshPatch(el, stats)
if (patch) set(patch)
}}
>
{t('journey.studio.refresh')}
</button>
</div>
)}
{el.kind === 'map' && <MapProps el={el} set={set} t={t} Section={Section} />}
{el.kind === 'stats' && <StatsProps el={el} set={set} t={t} Section={Section} />}
{el.kind === 'countries' && <CountriesProps el={el} set={set} t={t} Section={Section} />}
{el.kind === 'badge' && <BadgeProps el={el} set={set} t={t} Section={Section} />}
{el.kind === 'list' && <ListProps el={el} set={set} t={t} Section={Section} />}
{/*
* Type, for all five alike.
*
* These elements set their own words — a figure over its caption, a
* country's name over its outline — and until now the only thing you
* could change about that was the size. A book set in a serif with its
* stats block in the default sans is a book with one element in the
* wrong typeface and no way to fix it.
*/}
<Section label={t('journey.studio.typography')}>
<div className="st-fonts">
{BOOK_FONT_ORDER.map(id => {
const font = BOOK_FONTS[id]
return (
<button
key={id}
className={`st-font ${el.font === id ? 'is-on' : ''}`}
style={{ fontFamily: font.stack }}
onClick={() => set({
font: id,
// A family that does not ship this weight renders a
// synthesised bold — a smeared regular in print — so the
// weight moves to the nearest one it really has.
weight: nearestWeight(id, el.weight) as typeof el.weight,
} as Partial<BookElement>)}
title={font.name}
>
{font.name}
</button>
)
})}
</div>
<div className="st-row" style={{ marginTop: 8 }}>
{([400, 500, 600, 700] as const).map(w => (
<button
key={w}
className={`st-chip ${el.weight === w ? 'is-on' : ''}`}
disabled={!hasWeight(el.font, w)}
title={hasWeight(el.font, w) ? undefined : t('journey.studio.weightMissing')}
onClick={() => set({ weight: w } as Partial<BookElement>)}
>
{w}
</button>
))}
</div>
</Section>
{/*
* Colour, for all five alike.
*
* They ship with one warm accent so a book made without touching anything
* still reads as designed — but that is a starting point, not a house
* style. A book printed to match a cover, or one that simply should not be
* orange, needs both of these, and they are the same two properties on
* every travel element.
*/}
<Section label={t('journey.studio.accent')}>
<Swatches value={el.accent} onPick={c => set({ accent: c } as Partial<BookElement>)} />
</Section>
<Section label={t('journey.studio.colour')}>
{/*
Picking a colour is what turns automatic off.
A chip works out its own text colour from the fill it carries, which
is right until somebody wants something else — and then a separate
switch to find first is one step too many. So the swatch does both,
and the Automatic chip beside it is the way back rather than the way
in. Only marks have this; the other elements have no fill of their
own to answer to.
*/}
<Swatches
value={el.color}
onPick={c => set(
(el.kind === 'badge' ? { color: c, autoColor: false } : { color: c }) as Partial<BookElement>,
)}
/>
{el.kind === 'badge' && (
<div className="st-row" style={{ marginTop: 8 }}>
<Toggle
on={el.autoColor}
label={t('journey.studio.autoColour')}
onClick={() => set({ autoColor: !el.autoColor } as Partial<BookElement>)}
/>
</div>
)}
</Section>
<Section label={t('journey.studio.textScale')}>
<div className="st-row">
{([0.7, 0.85, 1, 1.2, 1.5] as const).map(scale => (
<button
key={scale}
className={`st-chip ${Math.abs(el.textScale - scale) < 0.01 ? 'is-on' : ''}`}
onClick={() => set({ textScale: scale } as Partial<BookElement>)}
>
{scale === 1 ? '1×' : `${scale}×`}
</button>
))}
</div>
</Section>
</>
)
}
function ListProps({ el, set, t, Section }: {
el: BookListElement
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
Section: TravelInspectorProps['Section']
}) {
return (
<Section label={t('journey.studio.addProsCons')}>
<div className="st-row">
{(['columns', 'stacked'] as const).map(layout => (
<button
key={layout}
className={`st-chip ${el.layout === layout ? 'is-on' : ''}`}
onClick={() => set({ layout } as Partial<BookElement>)}
>
{t(layout === 'columns' ? 'journey.studio.layoutGrid' : 'journey.studio.layoutColumn')}
</button>
))}
<Toggle
on={el.showMarks}
label={t('journey.studio.showMarks')}
onClick={() => set({ showMarks: !el.showMarks } as Partial<BookElement>)}
/>
</div>
<div className="st-grid2" style={{ marginTop: 8 }}>
<label className="st-field">
<span>{t('journey.editor.pros')}</span>
<input
className="st-input"
value={el.proLabel}
onChange={e => set({ proLabel: e.target.value } as Partial<BookElement>)}
/>
</label>
<label className="st-field">
<span>{t('journey.editor.cons')}</span>
<input
className="st-input"
value={el.conLabel}
onChange={e => set({ conLabel: e.target.value } as Partial<BookElement>)}
/>
</label>
</div>
</Section>
)
}
function MapProps({ el, set, t, Section }: {
el: BookMapElement
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
Section: TravelInspectorProps['Section']
}) {
const sources = useMapSources(el.frame, el.points)
return (
<>
<Section label={t('journey.studio.routeMap')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => (
<button
key={style}
className={`st-chip ${el.style === style ? 'is-on' : ''}`}
onClick={() => set({ style } as Partial<BookElement>)}
>
{t(`journey.studio.mapStyle.${style}`)}
</button>
))}
</div>
</Section>
<Section label={t('journey.studio.mapSource')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
{sources.map(src => (
<button
key={src.id}
className={`st-chip ${el.source === src.id ? 'is-on' : ''}`}
onClick={() => set({
source: src.id,
tileUrl: src.url,
// The credit travels with the source. Switching to imagery and
// leaving the previous attribution would print the wrong one.
attribution: src.attribution,
} as Partial<BookElement>)}
>
{t(src.labelKey)}
</button>
))}
</div>
{el.source !== 'vector' && (
<p className="st-hint" style={{ paddingTop: 6 }}>{t('journey.studio.mapSourceHint')}</p>
)}
</Section>
{/*
── What the map shows ─────────────────────────────────────────────
Three questions, and they are not the same question: how close the view
is, how much air is left around it, and what shape the picture is cut
to. The first two used to be decided for you, and the decision was
wrong often enough to be the loudest complaint about this element: a
trip that stayed inside one city was drawn as the whole country with
two dots in the middle of it.
*/}
<Section label={t('journey.studio.mapFraming')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
<button
className={`st-chip ${!el.fitToCountries ? 'is-on' : ''}`}
onClick={() => set({ fitToCountries: false } as Partial<BookElement>)}
>
{t('journey.studio.mapFitStops')}
</button>
<button
className={`st-chip ${el.fitToCountries ? 'is-on' : ''}`}
onClick={() => set({ fitToCountries: true } as Partial<BookElement>)}
>
{t('journey.studio.mapFitCountry')}
</button>
</div>
{/*
Room around what is drawn, as a share of it: the same setting has to
look right around a walk across a city and a drive across a
continent, and millimetres cannot do that.
*/}
<div className="st-row" style={{ marginTop: 8, flexWrap: 'wrap' }}>
{([
['journey.studio.mapPadTight', 0.04],
['journey.studio.mapPadNormal', 0.18],
['journey.studio.mapPadWide', 0.5],
['journey.studio.mapPadFar', 1.2],
] as const).map(([key, value]) => (
<button
key={key}
className={`st-chip ${Math.abs(el.fitPadding - value) < 0.001 ? 'is-on' : ''}`}
onClick={() => set({ fitPadding: value } as Partial<BookElement>)}
>
{t(key)}
</button>
))}
</div>
{/*
And the shape. A rectangle is a map in a box, which is what a full
page wants; cut to the coastline it stops being a figure and becomes
an illustration that can sit next to anything.
*/}
<div className="st-row" style={{ marginTop: 8, flexWrap: 'wrap' }}>
<button
className={`st-chip ${el.clip === 'rect' ? 'is-on' : ''}`}
onClick={() => set({ clip: 'rect' } as Partial<BookElement>)}
>
{t('journey.studio.mapClipRect')}
</button>
<button
className={`st-chip ${el.clip === 'country' ? 'is-on' : ''}`}
disabled={el.countries.length === 0}
title={el.countries.length === 0 ? t('journey.studio.mapClipNeedsCountry') : undefined}
onClick={() => set({ clip: 'country' } as Partial<BookElement>)}
>
{t('journey.studio.mapClipCountry')}
</button>
</div>
</Section>
{el.source === 'tiles' && (
<Section label={t('journey.studio.mapZoom')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
<button
className={`st-chip ${el.zoom === null ? 'is-on' : ''}`}
onClick={() => set({ zoom: null } as Partial<BookElement>)}
>
{t('journey.studio.mapZoomAuto')}
</button>
{[3, 5, 7, 9, 11, 13, 15, 17].map(z => (
<button
key={z}
className={`st-chip ${el.zoom === z ? 'is-on' : ''}`}
onClick={() => set({ zoom: z } as Partial<BookElement>)}
>
{z}
</button>
))}
</div>
</Section>
)}
<Section label={t('journey.studio.mapLayers')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
{el.source !== 'tiles' && el.source !== 'static' && (
<Toggle on={el.showLand} label={t('journey.studio.showLand')} onClick={() => set({ showLand: !el.showLand } as Partial<BookElement>)} />
)}
<Toggle on={el.showRoute} label={t('journey.studio.showRoute')} onClick={() => set({ showRoute: !el.showRoute } as Partial<BookElement>)} />
<Toggle on={el.showPins} label={t('journey.studio.showPins')} onClick={() => set({ showPins: !el.showPins } as Partial<BookElement>)} />
<Toggle on={el.showLabels} label={t('journey.studio.showLabels')} onClick={() => set({ showLabels: !el.showLabels } as Partial<BookElement>)} />
</div>
</Section>
</>
)
}
function StatsProps({ el, set, t, Section }: {
el: BookStatsElement
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
Section: TravelInspectorProps['Section']
}) {
const toggleMetric = (m: BookMetric) => {
const next = el.metrics.includes(m) ? el.metrics.filter(x => x !== m) : [...el.metrics, m]
// An empty panel would be a blank rectangle nobody could get back out of,
// so the last figure cannot be switched off.
if (!next.length) return
set({ metrics: next } as Partial<BookElement>)
}
return (
<>
<Section label={t('journey.studio.metrics')}>
<div className="st-row" style={{ flexWrap: 'wrap' }}>
{BOOK_METRICS.map(m => (
<Toggle
key={m}
on={el.metrics.includes(m)}
label={t(`journey.studio.metric.${m}`)}
onClick={() => toggleMetric(m)}
/>
))}
</div>
</Section>
<Section label={t('journey.studio.layout')}>
<div className="st-row">
{(['grid', 'row', 'column'] as const).map(layout => (
<button
key={layout}
className={`st-chip ${el.layout === layout ? 'is-on' : ''}`}
onClick={() => set({ layout } as Partial<BookElement>)}
>
{t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`)}
</button>
))}
</div>
<div className="st-row" style={{ marginTop: 8 }}>
{(['metric', 'imperial'] as const).map(units => (
<button
key={units}
className={`st-chip ${el.units === units ? 'is-on' : ''}`}
onClick={() => set({ units } as Partial<BookElement>)}
>
{units === 'metric' ? 'km' : 'mi'}
</button>
))}
<Toggle
on={el.showIcons}
label={t('journey.studio.elements')}
onClick={() => set({ showIcons: !el.showIcons } as Partial<BookElement>)}
/>
</div>
</Section>
</>
)
}
function CountriesProps({ el, set, t, Section }: {
el: BookCountriesElement
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
Section: TravelInspectorProps['Section']
}) {
return (
<Section label={t('journey.studio.countries')}>
<div className="st-row">
{(['list', 'grid', 'column'] as const).map(layout => (
<button
key={layout}
className={`st-chip ${el.layout === layout ? 'is-on' : ''}`}
onClick={() => set({ layout } as Partial<BookElement>)}
>
{t(`journey.studio.layout${layout[0].toUpperCase()}${layout.slice(1)}`)}
</button>
))}
</div>
<div className="st-row" style={{ marginTop: 8, flexWrap: 'wrap' }}>
<Toggle on={el.showOutline} label={t('journey.studio.showOutline')} onClick={() => set({ showOutline: !el.showOutline } as Partial<BookElement>)} />
<Toggle on={el.showFlag} label={t('journey.studio.showFlag')} onClick={() => set({ showFlag: !el.showFlag } as Partial<BookElement>)} />
<Toggle on={el.showName} label={t('journey.studio.showName')} onClick={() => set({ showName: !el.showName } as Partial<BookElement>)} />
</div>
<div className="st-row" style={{ marginTop: 8 }}>
{(['left', 'center', 'right'] as const).map(align => (
<button
key={align}
className={`st-chip ${el.align === align ? 'is-on' : ''}`}
onClick={() => set({ align } as Partial<BookElement>)}
>
{align}
</button>
))}
</div>
{/*
The names themselves, one per line.
They arrive resolved from the journey and are usually right, but "usually"
is not a thing you can print: a country whose English name nobody uses,
a territory the geocoder names differently from the way the trip talked
about it, a line that wants to read "Scotland" rather than "United
Kingdom". A textarea rather than a field per country because the list is
two names on one page and twenty on another, and lines are how anyone
already edits a list.
Editing only the names is deliberate: the codes stay as they were, so the
outlines and flags keep matching the places actually visited.
*/}
{el.showName && (
<label className="st-field" style={{ marginTop: 10 }}>
<span>{t('journey.studio.countryNames')}</span>
<textarea
className="st-input st-textarea"
rows={Math.min(8, Math.max(2, el.codes.length))}
value={el.names.join('\n')}
onChange={e => {
const lines = e.target.value.split('\n')
// One name per country, in the order they were visited: a removed
// line must not shift every name after it onto the wrong outline.
set({
names: el.codes.map((code, i) => (lines[i] ?? el.names[i] ?? code).slice(0, 80)),
} as Partial<BookElement>)
}}
/>
</label>
)}
</Section>
)
}
function BadgeProps({ el, set, t, Section }: {
el: BookBadgeElement
set: (patch: Partial<BookElement>) => void
t: (k: string) => string
Section: TravelInspectorProps['Section']
}) {
return (
<Section label={t('journey.studio.marks')}>
<label className="st-field">
<span>{t('journey.studio.text')}</span>
<input
className="st-input"
value={el.text}
onChange={e => set({ text: e.target.value } as Partial<BookElement>)}
/>
</label>
<label className="st-field" style={{ marginTop: 8 }}>
<span>{t('journey.studio.styleCaption')}</span>
<input
className="st-input"
value={el.sub}
onChange={e => set({ sub: e.target.value } as Partial<BookElement>)}
/>
</label>
<div className="st-row" style={{ marginTop: 8, flexWrap: 'wrap' }}>
{(['plain', 'chip', 'outline', 'stacked'] as const).map(style => (
<button
key={style}
className={`st-chip ${el.style === style ? 'is-on' : ''}`}
onClick={() => set({ style } as Partial<BookElement>)}
>
{style}
</button>
))}
</div>
</Section>
)
}
@@ -0,0 +1,372 @@
import type { BookElement, BookMetric, BookPageSetup, JourneyStats } from '@trek/shared'
import { BOOK_METRICS } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
import { PanelHead } from './StudioPanelHead'
import { TravelPreview } from './TravelPreview'
/**
* The journey's own figures, as things you can put on a page.
*
* This is the panel that makes Studio a *travel* book designer rather than a
* layout tool that happens to be full of holiday photographs. The route, the
* distance, the countries and the dates are already in TREK; the work is
* offering them as objects rather than as numbers someone has to retype.
*
* ── Why every tile is a real preview ──────────────────────────────────────
*
* These elements differ from one another by what they *say*, not by their
* shape. Four map styles are four maps; three summary layouts are the same six
* numbers arranged three ways. A named row with the value beside it — which is
* what this panel was first — tells you none of that: you cannot tell the dark
* map from the paper one, or a row of figures from a grid of them, until you
* have placed one and undone it.
*
* So each tile builds the element it would place and renders it through the
* page's own renderer, with this journey's real numbers in it. You are picking
* from the things themselves.
*
* Every button resolves its values *now* and writes them into the element, so
* what lands on the page is finished rather than a placeholder that needs the
* server to mean anything. The reasoning is in TravelElements.tsx, and the
* short version is that the print renderer must never depend on a fetch.
*/
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/*
* How many cards share a row.
*
* A stats panel is the width of a spread; shown two-up its figures scale down
* past reading, so it gets the row to itself. Maps, country lists and marks are
* compact enough that two-up still tells them apart, and two-up is what makes
* the section scannable. The tile measures its own width — see TravelPreview.
*/
/** Kilometres, grouped for the reader's locale. */
function formatDistance(metres: number, locale: string): string {
return `${new Intl.NumberFormat(locale, { maximumFractionDigits: 0 }).format(metres / 1000)} km`
}
/**
* Degrees and minutes with a hemisphere letter, the way a chart labels a
* position. The decimal form is what a machine reads; this is for a page
* someone is holding.
*/
function formatCoords(lat: number, lng: number): string {
const part = (v: number, pos: string, neg: string) => {
const deg = Math.floor(Math.abs(v))
const min = Math.round((Math.abs(v) - deg) * 60)
return `${deg}°${String(min).padStart(2, '0')}'${v >= 0 ? pos : neg}`
}
return `${part(lat, 'N', 'S')} ${part(lng, 'E', 'W')}`
}
function Card({ label, onClick, children }: {
label: string
onClick: () => void
children: React.ReactNode
}) {
return (
<button className="st-travel-card" onClick={onClick} title={label}>
{children}
<span className="st-travel-card-label">{label}</span>
</button>
)
}
export function StudioTravelPanel({
page, stats, path, t, locale,
}: {
page: BookPageSetup
stats: JourneyStats | null
/** The travelled way, already thinned. Empty when the trip has no geometry. */
path: [number, number][][]
t: (k: string) => string
locale: string
}) {
const addElement = useStudioStore(s => s.addElement)
const active = useStudioStore(s => s.activeSpread)
const doc = useStudioStore(s => s.doc)
const spread = doc?.spreads[active]
const single = !!spread && spread.role !== 'inner'
const centre = (w: number, h: number) => {
const W = single ? page.pageWidth : page.pageWidth * 2
return { x: (W - w) / 2, y: (page.pageHeight - h) / 2, w, h }
}
if (!stats) {
return (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<p className="st-hint">{t('journey.studio.travelEmpty')}</p>
</div>
</>
)
}
const base = {
rotation: 0, opacity: 1, locked: false,
font: 'sans' as const, color: '#1a1a1a', accent: '#111111', textScale: 1, weight: 700 as const,
stale: false,
}
/**
* Country names in the reader's language, not the server's.
*
* The API answers in English, because it has no idea who will read the book.
* `Intl.DisplayNames` is the same CLDR data Atlas already uses for regions, so
* a German book says "Island" and an English one says "Iceland" — from one set
* of codes, with no translation table to keep in step.
*/
const countryName = (code: string): string => {
try {
const display = new Intl.DisplayNames([locale], { type: 'region' })
return display.of(code.toUpperCase()) || code.toUpperCase()
} catch {
return stats.countries.find(c => c.code === code)?.name || code.toUpperCase()
}
}
const values: Record<string, number> = {
distance: stats.distance,
days: stats.days,
steps: stats.steps,
photos: stats.photos,
countries: stats.countries.length,
places: stats.places,
furthest: stats.furthest,
}
/*
* Each builder returns the finished element. The tile renders it and the
* click places it, so the preview and the result are the same object — there
* is no second description of what a card means.
*/
const mapSide = Math.min(page.pageWidth, page.pageHeight) * 0.72
/**
* One country, as the entry a list would have made of it.
*
* The list and the grid are gone: they were compositions somebody else made,
* deciding the order, the spacing and the type size, and the only way to
* change any of that was to not use them. What is left is the same entry —
* name over silhouette, set the same way — as an element you place one at a
* time. Three of them down a page is the list, with the spacing yours; one of
* them at 80mm is a chapter opener, which the list could never be.
*
* The name is editable afterwards, so numbering them "1. Germany",
* "2. Netherlands" is typing rather than a feature.
*/
const countryEl = (code: string): BookElement => ({
...base, id: uid('co'), kind: 'countries',
frame: centre(page.pageWidth * 0.34, 34),
codes: [code], names: [countryName(code)],
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
} as BookElement)
const mapEl = (
style: 'minimal' | 'outline' | 'dark' | 'paper',
source: 'vector' | 'tiles' | 'static' = 'vector',
url = '',
attribution = '',
clip: 'rect' | 'country' = 'rect',
): BookElement => ({
...base, id: uid('mp'), kind: 'map',
frame: centre(mapSide, mapSide * 0.78),
style, source, tileUrl: url, attribution, zoom: null, clip,
showLand: true, showRoute: true, showPins: true, showLabels: false,
countries: stats.countries.map(c => c.code),
points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label })),
/*
* Frozen into the element, like every other travel figure: a page that
* fetches its own route at print time is a page that changes when someone
* edits the trip, and prints empty when the export runs signed out.
*/
path,
fitPadding: 0.18,
fitToCountries: true,
} as BookElement)
/*
* A mark is placed at the size that suits what it holds.
*
* One size for all six put a flag and a country name into a box built for a
* line of coordinates, which is how a small mark ends up floating inside a
* large selection rectangle. Fractions of the page, so the proportions hold
* on any format.
*/
const MARK_SIZE: Record<string, [number, number]> = {
flag: [0.25, 0.062],
date: [0.16, 0.14],
day: [0.17, 0.062],
coords: [0.36, 0.05],
country: [0.22, 0.078],
distance: [0.26, 0.066],
}
const badgeEl = (
variant: 'flag' | 'date' | 'day' | 'coords' | 'country' | 'distance',
text: string,
sub: string,
code: string | null,
style: 'plain' | 'chip' | 'outline' | 'stacked' = 'plain',
): BookElement => {
const [fw, fh] = MARK_SIZE[variant] ?? [0.24, 0.08]
return {
...base, id: uid('bd'), kind: 'badge', autoColor: true,
frame: centre(page.pageWidth * fw, page.pageHeight * fh),
variant, text, sub, code, style,
} as BookElement
}
/**
* One figure on its own.
*
* A summary panel is a composition; a single figure is a mark you drop next
* to a photograph. Both are the same element with a different metric list,
* and offering only the composition meant anyone wanting "14 DAYS" beside a
* picture had to place all four and delete three.
*/
const singleEl = (metric: BookMetric): BookElement => ({
...base, id: uid('st'), kind: 'stats',
frame: centre(page.pageWidth * 0.22, page.pageHeight * 0.13),
metrics: [metric], layout: 'grid', showIcons: true, units: 'metric', values,
} as BookElement)
/** Place a copy — the previewed element keeps its own id for React. */
const place = (el: BookElement) => addElement(active, { ...el, id: uid(el.kind[0]) } as BookElement)
const first = stats.points[0] ?? null
const firstCountry = stats.countries[0] ?? null
const startDay = stats.start ? new Date(`${stats.start}T00:00:00`) : null
const marks: { el: BookElement; label: string }[] = []
if (startDay) {
marks.push({
el: badgeEl('date', String(startDay.getDate()),
startDay.toLocaleDateString(locale, { month: 'long' }).toUpperCase(), null, 'stacked'),
label: t('journey.studio.dateMark'),
})
}
marks.push({
el: badgeEl('day', `${t('journey.studio.dayWord')} 1`, '', null, 'chip'),
label: t('journey.studio.dayMark'),
})
if (first) {
marks.push({
el: badgeEl('coords', formatCoords(first.lat, first.lng), first.label, null),
label: t('journey.studio.coordsMark'),
})
}
if (firstCountry) {
marks.push({
el: badgeEl('flag', '', countryName(firstCountry.code), firstCountry.code),
label: t('journey.studio.flagMark'),
})
}
marks.push({
el: badgeEl('distance', formatDistance(stats.distance, locale), t('journey.studio.metric.distance'), null, 'outline'),
label: t('journey.studio.distanceMark'),
})
return (
<>
<PanelHead label={t('journey.studio.travel')} />
<div className="st-panel-scroll">
<div className="st-section">
<div className="st-section-label">{t('journey.studio.singleFigures')}</div>
<div className="st-travel-grid">
{BOOK_METRICS.map(metric => {
const el = singleEl(metric)
return (
<Card key={metric} label={t(`journey.studio.metric.${metric}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={62} />
</Card>
)
})}
</div>
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.routeMap')}</div>
{stats.points.length ? (
<>
<div className="st-travel-grid">
{(['minimal', 'outline', 'paper', 'dark'] as const).map(style => {
const el = mapEl(style)
return (
<Card key={style} label={t(`journey.studio.mapStyle.${style}`)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={62} maxHeight={80} />
</Card>
)
})}
</div>
</>
) : (
<p className="st-hint">{t('journey.studio.noRoute')}</p>
)}
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.countries')}</div>
{stats.countries.length ? (
<>
{/*
One country per element, rather than a block listing them all.
A list of countries is a composition somebody else made: it
decides the order, the spacing and the type size, and the only
way to change any of it is to not use it. Placed one at a time
they are ordinary elements — numbered, moved, set at different
sizes, put on different pages — which is what a book of a trip
through several countries actually wants.
*/}
<div className="st-travel-grid">
{stats.countries.slice(0, 12).map(c => {
const el = countryEl(c.code)
return (
<Card key={c.code} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={44} maxHeight={64} />
</Card>
)
})}
</div>
{/*
And the same country as a mark: the outline beside the name
rather than under it, at the size of a line of type. It belongs
next to a date or a coordinate, not on a page of its own.
*/}
<div className="st-travel-grid" style={{ marginTop: 6 }}>
{stats.countries.slice(0, 12).map(c => {
const el = badgeEl('country', countryName(c.code), '', c.code)
return (
<Card key={`mark-${c.code}`} label={countryName(c.code)} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={30} maxHeight={44} />
</Card>
)
})}
</div>
</>
) : (
<p className="st-hint">{t('journey.studio.noCountries')}</p>
)}
</div>
<div className="st-section">
<div className="st-section-label">{t('journey.studio.marks')}</div>
<div className="st-travel-grid">
{marks.map(({ el, label }) => (
<Card key={el.id} label={label} onClick={() => place(el)}>
<TravelPreview el={el} minHeight={34} maxHeight={52} />
</Card>
))}
</div>
</div>
</div>
</>
)
}
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,74 @@
import type { BookElement } from '@trek/shared'
import { useElementSize } from '../../hooks/useElementSize'
import { ElementView } from './SpreadView'
/**
* A travel element, drawn small.
*
* The same component the page uses, at a smaller scale — not a second drawing
* of it. This is the trick the page thumbnails already use, and it is worth
* more here than anywhere else in Studio: these elements differ from each other
* by *what they say*, not by their shape, so a generic icon of a map tells you
* nothing about which of the four map styles you are about to place. Rendered
* for real, with this journey's own figures, the tile answers the question the
* label was only gesturing at.
*
* It also cannot drift. If the map gains a layer or the summary changes how it
* sets a figure, the preview shows that on the same commit.
*
* ── Why the width is measured ─────────────────────────────────────────────
*
* The tile is a grid cell, so its width is the panel's minus padding, gaps, the
* card's own border and whatever the scrollbar takes. Passing a guess in was
* tried and was two pixels out, which `overflow: hidden` turned into a clipped
* right edge. Measuring costs one ResizeObserver and cannot be wrong.
*/
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact. */
const PX_PER_MM = 96 / 25.4
export function TravelPreview({ el, minHeight = 30, maxHeight = 90 }: {
el: BookElement
minHeight?: number
maxHeight?: number
}) {
const box = useElementSize<HTMLSpanElement>()
const width = box.width
/*
* The tile takes the element's proportions rather than a fixed box.
*
* A stats panel is three times wider than it is tall and a country list is
* taller than it is wide. Fitted into one shape, the wide ones would sit in a
* thin band with empty tile above and below — and, worse, would be scaled
* down to fit a height they did not need, which is what made the figures
* unreadable. Following the element uses the full width for everything and
* costs only a slightly uneven grid.
*/
const drawnW = el.frame.w * PX_PER_MM
const drawnH = el.frame.h * PX_PER_MM
const height = Math.round(Math.min(maxHeight, Math.max(minHeight, (width * drawnH) / drawnW || minHeight)))
const scale = width > 0 ? Math.min(width / drawnW, height / drawnH) : 0
return (
<span className="st-travel-preview" style={{ height }} ref={box.ref} aria-hidden>
{scale > 0 && (
<span
style={{
position: 'absolute',
left: (width - drawnW * scale) / 2,
top: (height - drawnH * scale) / 2,
width: `${el.frame.w}mm`,
height: `${el.frame.h}mm`,
transform: `scale(${scale})`,
transformOrigin: 'top left',
}}
>
{/* Drawn at the origin: the tile is the frame, so the element's own
position on the spread is not part of what is being shown. */}
<ElementView el={{ ...el, frame: { ...el.frame, x: 0, y: 0 } } as BookElement} big={false} />
</span>
)}
</span>
)
}
@@ -0,0 +1,66 @@
import { useState } from 'react'
import { PAGE_MAX_MM, PAGE_MIN_MM } from './pagePresets'
/**
* One side of the trim size.
*
* ── Why it holds a draft ─────────────────────────────────────────────────
*
* The value goes through `clampPageSize` on the way in, and it used to do so on
* every keystroke: typing 500 meant the field saw 5, clamped it to the minimum
* and wrote that back under the cursor, so the number could not be typed at
* all — only nudged with the arrows. So while a number is being typed the field
* keeps what was typed, and the document only hears about values it can use.
*
* The arrows still work as before, because a value they produce is already in
* range and is committed the moment it appears. Leaving the field commits what
* is there, clamped — someone who typed 5 and clicked away meant the smallest
* book, not the one they had.
*/
export function TrimField({
label, value, onCommit,
}: {
label: string
value: number
onCommit: (mm: number) => void
}) {
const [draft, setDraft] = useState<string | null>(null)
const shown = draft ?? String(Math.round(value * 10) / 10)
const settle = () => {
if (draft === null) return
const n = Number(draft)
if (draft.trim() !== '' && Number.isFinite(n)) onCommit(n)
setDraft(null)
}
return (
<label>
<span>{label}</span>
<input
type="number"
inputMode="decimal"
value={shown}
min={PAGE_MIN_MM}
max={PAGE_MAX_MM}
step={1}
onChange={e => {
const raw = e.target.value
setDraft(raw)
// Anything already usable goes straight through, so the arrows and a
// fully typed number both take effect without waiting for a blur.
const n = Number(raw)
if (raw !== '' && Number.isFinite(n) && n >= PAGE_MIN_MM && n <= PAGE_MAX_MM) {
setDraft(null)
onCommit(n)
}
}}
onBlur={settle}
onKeyDown={e => {
if (e.key === 'Enter') e.currentTarget.blur()
if (e.key === 'Escape') setDraft(null)
}}
/>
</label>
)
}
@@ -0,0 +1,238 @@
import type { BookElement, BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import type { SpreadTemplate } from './bookTemplates.data'
/**
* Laying an entry out on a template somebody drew.
*
* ── Why this exists ──────────────────────────────────────────────────────
*
* The layouts written as code in autoLayout.ts are what a function can reason
* its way to: a picture here, words there, margins that add up. What they are
* not is *designed* — a panel bled off one edge, a shape running off the
* corner, a strip of pictures at two sizes with the coordinates set under them.
* Those decisions come from looking at a page, and the editor is where looking
* at a page happens.
*
* So the templates are drawn in Studio and read back out (see
* bookTemplates.data.ts). This is the other half: taking one of those spreads
* and putting an entry into it.
*
* ── What it fills, and what it leaves alone ──────────────────────────────
*
* Only what the entry can answer for:
*
* - text carrying a `binding` — the title, the story, a caption
* - empty photo frames, taking the entry's photographs in order
* - the day, coordinate, date and country marks
* - the stats and countries elements, from the journey's figures
*
* Everything else is the design and is copied as drawn. A panel stays where it
* was put; a decorative shape keeps its colour. That division is the whole
* contract between the person who draws a template and the code that uses it.
*
* ── Sizes ────────────────────────────────────────────────────────────────
*
* The template's numbers are fractions of the page it was drawn on, so they are
* multiplied back up here. A template built on a 210mm square lays out on an A4
* landscape book without being redrawn.
*/
export interface TemplateEntry {
id: number
title: string | null
story: string | null
location: string | null
date: string | null
photos: { photoId: number; width: number | null; height: number | null; caption?: string | null }[]
lat?: number | null
lng?: number | null
country?: string | null
dayNumber?: number | null
dayCount?: number | null
}
export interface TemplateContext {
page: BookPageSetup
locale: string
stats: JourneyStats | null
/** Already translated, because this module has no translation context. */
dayLabel: string
}
let seq = 0
const uid = (p: string) => `${p}-${(seq++).toString(36)}-${Math.floor(Math.random() * 1e6).toString(36)}`
/** How well a template suits an entry: more is better, -1 means unusable. */
export function templateFit(template: SpreadTemplate, entry: TemplateEntry): number {
const frames = template.elements.filter(e => e.kind === 'photo').length
const photos = entry.photos.length
const wantsStory = template.elements.some(e => e.kind === 'text' && e.binding?.source === 'entry.story')
const hasStory = !!(entry.story || '').trim()
/*
* A template is a template *for an entry* when it uses one.
*
* The distinction has to come out of the drawing rather than from a label
* somebody remembers to set: a summary spread is all figures and country
* outlines and reads perfectly well on its own, and dropping a day's
* photographs into it would be nonsense. Bindings are how a page says it
* expects an entry.
*/
const usesEntry = template.elements.some(
e => ('binding' in e && e.binding) || (e.kind === 'photo' && e.photoId == null),
)
if (!usesEntry) return -1
/*
* A template with three frames and an entry with one picture leaves two
* placeholders on a finished page, which is worse than a simpler layout.
* More pictures than frames is fine — the extra ones are parked.
*/
if (frames > photos + 1) return -1
if (wantsStory && !hasStory) return -1
// Closest match wins: a two-frame template for two pictures beats a one-frame
// one, and both beat a template that would leave a hole.
return 100 - Math.abs(frames - photos) * 10 - (wantsStory === hasStory ? 0 : 5)
}
/** Fill a template with an entry, and hand back the spread it makes. */
export function applyTemplate(
template: SpreadTemplate,
entry: TemplateEntry,
ctx: TemplateContext,
): BookSpread {
const { page } = ctx
const PW = page.pageWidth
const PH = page.pageHeight
let nextPhoto = 0
const story = (entry.story || '').trim()
const heading = entry.title || entry.location || ''
const elements = template.elements.map(el => {
const out = {
...el,
id: uid(el.kind[0]),
frame: {
x: round2(el.frame.x * PW),
y: round2(el.frame.y * PH),
w: round2(el.frame.w * PW),
h: round2(el.frame.h * PH),
},
} as BookElement
// Sizes came out as fractions of the page height, so they go back up.
if ('size' in out && typeof out.size === 'number') out.size = round2(out.size * PH)
if ('radius' in out && typeof out.radius === 'number') out.radius = round2(out.radius * PW)
if ('strokeWidth' in out && typeof out.strokeWidth === 'number') {
out.strokeWidth = round2(out.strokeWidth * PW)
}
if (out.kind === 'text' && out.binding) {
const source = out.binding.source
if (source === 'entry.title') out.text = heading
else if (source === 'entry.story') out.text = story
else if (source === 'entry.date') out.text = formatDate(entry.date, ctx.locale)
out.binding = { ...out.binding, entryId: entry.id }
}
if (out.kind === 'photo' && out.photoId == null) {
const photo = entry.photos[nextPhoto++]
if (photo) out.photoId = photo.photoId
}
if (out.kind === 'badge') fillBadge(out, entry, ctx)
if (out.kind === 'countries' && ctx.stats) {
out.codes = ctx.stats.countries.map(c => c.code)
out.names = ctx.stats.countries.map(c => c.name)
}
// The figures live in one map on the element, keyed by metric — the same
// shape the panels write when a stats element is placed by hand.
if (out.kind === 'stats' && ctx.stats) {
out.values = {
distance: ctx.stats.distance,
days: ctx.stats.days,
steps: ctx.stats.steps,
photos: ctx.stats.photos,
places: ctx.stats.places,
countries: ctx.stats.countries.length,
furthest: ctx.stats.furthest,
}
}
return out
})
return {
id: uid('sp'),
role: 'inner',
background: template.background,
elements,
parked: [],
entryId: entry.id,
}
}
/**
* The small marks, filled from the stop.
*
* A badge whose value the entry cannot answer keeps the one it was drawn with:
* a template showing "DAY 1" on an entry with no date is a template that reads
* as a placeholder, which is exactly what it is until a date exists.
*/
function fillBadge(el: BookElement & { kind: 'badge' }, entry: TemplateEntry, ctx: TemplateContext) {
if (el.variant === 'day' && entry.dayNumber) {
el.text = `${ctx.dayLabel} ${entry.dayNumber}`
}
if (el.variant === 'coords' && entry.lat != null && entry.lng != null) {
el.text = coords(entry.lat, entry.lng)
}
if ((el.variant === 'flag' || el.variant === 'country') && entry.country) {
el.code = entry.country
el.text = countryName(entry.country, ctx).toUpperCase()
}
if (el.variant === 'date' && entry.date) {
const d = new Date(`${entry.date}T00:00:00`)
if (!Number.isNaN(d.getTime())) {
el.text = String(d.getDate())
el.sub = d.toLocaleDateString(ctx.locale, { month: 'long' }).toUpperCase()
}
}
if (el.variant === 'distance' && ctx.stats?.distance) {
el.text = `${Math.round(ctx.stats.distance / 1000).toLocaleString('en-US')} km`
}
}
function countryName(code: string, ctx: TemplateContext): string {
const known = ctx.stats?.countries.find(c => c.code === code)
try {
return new Intl.DisplayNames([ctx.locale], { type: 'region' }).of(code) ?? known?.name ?? code
} catch {
return known?.name ?? code
}
}
/** Degrees, minutes and seconds, the way a place page prints them. */
function coords(lat: number, lng: number): string {
const dms = (v: number, pos: string, neg: string) => {
const hemisphere = v >= 0 ? pos : neg
const abs = Math.abs(v)
const deg = Math.floor(abs)
const min = Math.floor((abs - deg) * 60)
const sec = Math.round((((abs - deg) * 60) - min) * 60)
return `${deg}° ${min}' ${sec}" ${hemisphere}`
}
return `${dms(lat, 'N', 'S')} ${dms(lng, 'E', 'W')}`
}
function formatDate(iso: string | null, locale: string): string {
if (!iso) return ''
const d = new Date(`${iso}T00:00:00`)
if (Number.isNaN(d.getTime())) return ''
return d.toLocaleDateString(locale, { day: 'numeric', month: 'long', year: 'numeric' })
}
const round2 = (n: number) => Math.round(n * 100) / 100
File diff suppressed because it is too large Load Diff
+141
View File
@@ -0,0 +1,141 @@
/**
* The book's typefaces.
*
* ── Why this file exists ──────────────────────────────────────────────────
*
* The font stack used to name Georgia for its serif. Georgia is a system font:
* it is on most desktops and on no Linux container, so the print renderer would
* quietly substitute something else and a book proofread in one typeface would
* print in another. The existing Journey PDF export has the same bug today — it
* asks for Inter while only Poppins, Geist and MuseoModerno are bundled.
*
* So every family here is self-hosted through @fontsource, all of them OFL, and
* every stack ends in a generic rather than in another named font nobody
* shipped. What the editor draws is what the renderer has.
*
* ── Why these seven ───────────────────────────────────────────────────────
*
* A photo book needs more than one voice and fewer than a menu. Two workhorse
* sans faces, three serifs across the range from bookish to high-contrast, and
* two display faces for a cover. Anything past that is a font picker rather
* than a design decision.
*/
export interface BookFont {
id: BookFontId
/** What it is called, shown as itself in the picker. */
name: string
/** The CSS stack. Ends generic — never in a font that is not bundled. */
stack: string
/** Where it belongs, for grouping the picker. */
kind: 'sans' | 'serif' | 'display'
/**
* Weights actually loaded. Asking for 700 on a family that ships one weight
* gets a synthesised bold, which prints as a smeared version of the regular.
*/
weights: number[]
}
export type BookFontId =
| 'sans' | 'serif' | 'display'
| 'inter' | 'garamond' | 'playfair' | 'bebas'
/**
* `sans`, `serif` and `display` keep their names because documents already
* contain them — the three original slots, now pointing at bundled families
* rather than at whatever the machine happened to have.
*/
export const BOOK_FONTS: Record<BookFontId, BookFont> = {
sans: {
id: 'sans',
name: 'Poppins',
stack: '"Poppins", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
inter: {
id: 'inter',
name: 'Inter',
stack: '"Inter", system-ui, sans-serif',
kind: 'sans',
weights: [400, 500, 600, 700],
},
serif: {
id: 'serif',
name: 'Lora',
stack: '"Lora", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
garamond: {
id: 'garamond',
name: 'EB Garamond',
stack: '"EB Garamond", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
playfair: {
id: 'playfair',
name: 'Playfair Display',
stack: '"Playfair Display", Georgia, serif',
kind: 'serif',
weights: [400, 500, 600, 700],
},
display: {
id: 'display',
name: 'MuseoModerno',
stack: '"MuseoModerno", "Poppins", system-ui, sans-serif',
kind: 'display',
weights: [400, 500, 600, 700],
},
bebas: {
id: 'bebas',
name: 'Bebas Neue',
stack: '"Bebas Neue", Impact, sans-serif',
kind: 'display',
// One weight only. Asking for bold gets a synthesised one, which is why
// the inspector greys the weights this family does not have.
weights: [400],
},
}
// Geist Sans is deliberately absent: it is TREK's own subtext face, part of the
// app's voice rather than the book's, and a book set in the interface font
// reads as a screenshot of the app.
/** The picker's order: workhorses first, display last. */
export const BOOK_FONT_ORDER: BookFontId[] = [
'sans', 'inter',
'serif', 'garamond', 'playfair',
'display', 'bebas',
]
/**
* The CSS stack for a font id.
*
* Falls back to Poppins rather than to a generic, so a document naming a family
* that was removed still renders in something the renderer has instead of in
* whatever the machine defaults to.
*/
export function fontStack(id: string): string {
return BOOK_FONTS[id as BookFontId]?.stack ?? BOOK_FONTS.sans.stack
}
/** Whether a family actually ships the weight being asked for. */
export function hasWeight(id: string, weight: number): boolean {
const font = BOOK_FONTS[id as BookFontId]
return font ? font.weights.includes(weight) : true
}
/**
* The nearest weight a family really has.
*
* A synthesised bold is a smeared regular in print, so a family with one weight
* gets that weight rather than a request the renderer has to fake.
*/
export function nearestWeight(id: string, weight: number): number {
const font = BOOK_FONTS[id as BookFontId]
if (!font || font.weights.includes(weight)) return weight
return font.weights.reduce((best, w) =>
(Math.abs(w - weight) < Math.abs(best - weight) ? w : best), font.weights[0])
}
@@ -0,0 +1,25 @@
/**
* The few things both the page renderer and the panels around it need.
*
* Separate from SpreadView.tsx only so that file exports components and nothing
* else — a module that mixes the two breaks React Fast Refresh, and this is
* exactly the kind of file you edit constantly while designing.
*/
/**
* The book's typefaces.
*
* Poppins ships with the client already; Georgia is a system serif. Print will
* need these self-hosted and identical on the server, or the renderer silently
* substitutes — which is what the current Journey PDF export does today, asking
* for Inter while only Poppins is bundled.
*/
export const FONT_STACKS: Record<string, string> = {
sans: '"Poppins", system-ui, sans-serif',
serif: 'Georgia, "Times New Roman", serif',
display: '"MuseoModerno", "Poppins", system-ui, sans-serif',
}
export function photoSrc(photoId: number, big: boolean): string {
return `/api/photos/${photoId}/${big ? 'original' : 'thumbnail'}`
}
+159
View File
@@ -0,0 +1,159 @@
import type { BookDocument, BookSpread } from '@trek/shared'
/**
* Cutting a book into printable sheets.
*
* ── Why a sheet is not a spread ──────────────────────────────────────────
*
* The document is made of spreads, because that is the unit a person designs
* in: a picture crossing the gutter is one decision, not two. A printer takes
* pages — single leaves, each with its own bleed on all four sides, in reading
* order. So the export cuts every inner spread down the middle and hands over
* the halves.
*
* The cut is a window, not a re-layout. Each sheet shows the same spread
* through an opening the size of one page, shifted by a page width for the
* right-hand leaf, which is why a photograph that crosses the gutter comes out
* of the press aligned: both halves were positioned by the same numbers.
*
* ── Why the spread mode exists anyway ────────────────────────────────────
*
* For reading, not for printing. A book PDF that opens two pages at a time is
* what you send someone to look at, and cutting it into leaves for that would
* make every photograph across the gutter look broken.
*
* ── Millimetres, all of it ───────────────────────────────────────────────
*
* Every number here is millimetres, matching the document. The renderer maps mm
* onto the PDF at a fixed ratio, so these are the sizes the press measures.
*/
/** How far a crop mark reaches out past the bleed. */
export const MARK_LENGTH = 4
/** How thick it is drawn — hairline, the way a mark should be. */
export const MARK_WEIGHT = 0.2
export type SheetMode = 'pages' | 'spreads'
export interface Sheet {
/** The spread this leaf is cut from. */
spread: BookSpread
/** Its index in the document, which is what the folios count from. */
spreadIndex: number
/**
* How far into the spread the window sits, in millimetres.
*
* 0 for a left-hand leaf, one page width for a right-hand one. It is a shift
* of the *view*, never of the contents.
*/
offset: number
/** Trim width of this leaf. */
width: number
/** Trim height, which never varies. */
height: number
/** Width of the whole spread behind the window, for positioning it. */
spreadWidth: number
/**
* Whether this leaf is one page rather than two.
*
* Covers are, in both modes. It matters to the print CSS: a document that
* mixes widths needs a second `@page` rule for the narrow sheets, or they get
* laid onto the wide page box with white either side.
*/
single: boolean
/** A label for the print view — "3", or "2 – 3" for a spread. */
label: string
}
/** How big one printed sheet is, bleed and crop marks included. */
export interface SheetBox {
/** Sheet width including bleed and any room for marks. */
width: number
height: number
/** Distance from the sheet edge to the trim edge. */
margin: number
/** The bleed itself, which is part of that margin. */
bleed: number
}
export function sheetBox(
trimWidth: number,
trimHeight: number,
bleed: number,
marks: boolean,
): SheetBox {
/*
* Marks are drawn outside the bleed, not inside it. Putting them in the bleed
* would mean printing them onto the part of the sheet that gets cut off —
* they would be trimmed away along with the thing they were marking.
*/
const margin = marks ? bleed + MARK_LENGTH : bleed
return {
width: trimWidth + margin * 2,
height: trimHeight + margin * 2,
margin,
bleed,
}
}
/**
* A book, as leaves.
*
* Covers stay whole in both modes: a cover is one page, and half a cover is
* not a thing anybody prints.
*/
export function sheetsFor(doc: BookDocument, mode: SheetMode): Sheet[] {
const { pageWidth, pageHeight, pageNumbers } = doc.page
const out: Sheet[] = []
doc.spreads.forEach((spread, spreadIndex) => {
const single = spread.role !== 'inner'
const spreadWidth = single ? pageWidth : pageWidth * 2
if (single || mode === 'spreads') {
out.push({
spread,
spreadIndex,
offset: 0,
width: spreadWidth,
height: pageHeight,
spreadWidth,
single,
label: single ? '' : folioRange(spreadIndex, pageNumbers.startAt),
})
return;
}
for (const half of [0, 1]) {
out.push({
spread,
spreadIndex,
offset: half * pageWidth,
width: pageWidth,
height: pageHeight,
spreadWidth,
single: true,
label: String(folio(spreadIndex, pageNumbers.startAt) + half),
})
}
})
return out
}
/**
* The number the left-hand page of a spread carries.
*
* Mirrors PageNumbers.tsx: the first inner spread opens at `startAt`, and the
* cover is a separate sheet, which is why the index is offset rather than used
* raw. It is only ever a label here — nothing is printed from it.
*/
function folio(spreadIndex: number, startAt: number): number {
return startAt + (spreadIndex - 1) * 2
}
function folioRange(spreadIndex: number, startAt: number): string {
const left = folio(spreadIndex, startAt)
return `${left} – ${left + 1}`
}
@@ -0,0 +1,553 @@
import type { BookElement } from '@trek/shared'
/**
* The spread templates, as data.
*
* ── Where these come from ────────────────────────────────────────────────
*
* Not from this file's author. They were built by hand in Studio and read back
* out of the document — which is the point: what a page should look like is a
* matter of taste, and taste is not something a layout function arrives at by
* reasoning. The editor is the design tool, and this is its output.
*
* To add one: build the spread in Studio, then run
*
* node scripts/extract-templates.cjs <journeyId>
*
* from server/ and commit what it writes here.
*
* ── Why the numbers are fractions ────────────────────────────────────────
*
* Every frame is divided by the page it was drawn on — x and w by one page's
* width, y and h by its height — and type sizes by the page height. A template
* built on a 210mm square therefore lays out on an A4 landscape book without
* being redrawn, which it would otherwise have to be for every trim size the
* picker offers.
*
* ── What gets filled in ──────────────────────────────────────────────────
*
* Elements carrying a `binding` take their text from the entry; empty photo
* frames take its photographs in order; the day, coordinate and country marks
* take what the entry knows about its stop. Everything else — the panels, the
* rules, the shapes that run off the page — is the design, and is kept as it
* was drawn.
*/
export interface SpreadTemplate {
id: string
background: string | null
/** Frames and sizes are fractions — see the note above. */
elements: BookElement[]
}
export const SPREAD_TEMPLATES: SpreadTemplate[] = [
{
id: 'ref-1',
background: null,
elements: [
{
"id": "p-flxgo8u",
"frame": {
"x": 0.0238,
"y": 0.0238,
"w": 0.9524,
"h": 0.9524
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "photo",
"photoId": null,
"fit": "cover",
"focalX": 0.5,
"focalY": 0.5,
"radius": 0,
"filter": "none",
"mask": null,
"frameStyle": "none"
},
{
"id": "s-iqku6eo",
"frame": {
"x": 1.5898,
"y": 0.0238,
"w": 0.3864,
"h": 0.9524
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "shape",
"shape": "rect",
"fill": "#11224f",
"gradient": "none",
"stroke": null,
"strokeWidth": 0,
"strokeStyle": "solid",
"radius": 0
},
{
"id": "t-nszckjj",
"frame": {
"x": 1.5898,
"y": 0.0563,
"w": 0.3864,
"h": 0.0613
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "text",
"text": "COUNTRIES",
"font": "sans",
"size": 0.1429,
"weight": 700,
"italic": false,
"align": "center",
"leading": 1.1,
"tracking": -0.02,
"color": "#ffffff",
"binding": null,
"overridden": true
},
{
"id": "t-6ntyuak",
"frame": {
"x": 1.1136,
"y": 0.0563,
"w": 0.3864,
"h": 0.0613
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "text",
"text": "SUMMARY",
"font": "sans",
"size": 0.1429,
"weight": 700,
"italic": false,
"align": "center",
"leading": 1.1,
"tracking": -0.02,
"color": "#111111",
"binding": null,
"overridden": true
},
{
"id": "s-v58hbqa",
"frame": {
"x": 1.0812,
"y": 0.1757,
"w": 0.4511,
"h": 0.5743
},
"rotation": 0,
"opacity": 1,
"locked": false,
"font": "sans",
"color": "#1a1a1a",
"accent": "#111111",
"textScale": 1.2,
"weight": 700,
"stale": false,
"kind": "stats",
"metrics": [
"distance",
"days",
"steps",
"photos",
"countries",
"places"
],
"layout": "grid",
"showIcons": true,
"units": "metric",
"values": {
"distance": 352677,
"days": 1,
"steps": 2,
"photos": 0,
"countries": 2,
"places": 0,
"furthest": 352677
}
},
{
"id": "b-u0di1u7",
"frame": {
"x": 1.1768,
"y": 0.7603,
"w": 0.26,
"h": 0.066
},
"rotation": 0,
"opacity": 1,
"locked": false,
"font": "sans",
"color": "#1a1a1a",
"accent": "#111111",
"textScale": 1,
"weight": 700,
"stale": false,
"kind": "badge",
"autoColor": true,
"variant": "distance",
"text": "0 km",
"sub": "Distance",
"code": null,
"style": "chip"
},
{
"id": "c-roas8tw",
"frame": {
"x": 1.628,
"y": 0.2397,
"w": 0.31,
"h": 0.537
},
"rotation": 0,
"opacity": 1,
"locked": false,
"font": "sans",
"color": "#ffffff",
"accent": "#ffffff",
"textScale": 0.7,
"weight": 700,
"stale": false,
"kind": "countries",
"codes": [
"DE",
"NL"
],
"names": [
"Germany",
"Netherlands"
],
"layout": "list",
"showOutline": true,
"showFlag": false,
"showName": true,
"align": "center"
}
],
},
{
id: 'ref-2',
background: null,
elements: [
{
"id": "s-f7yltlr",
"frame": {
"x": -0.1277,
"y": 0.6775,
"w": 0.5595,
"h": 0.5973
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "shape",
"shape": "flower-5",
"fill": "#11254c",
"gradient": "none",
"stroke": null,
"strokeWidth": 0,
"strokeStyle": "solid",
"radius": 0
},
{
"id": "s-0fpxqfa",
"frame": {
"x": 1.0762,
"y": 0.6437,
"w": 0.8635,
"h": 0.2829
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "shape",
"shape": "rect",
"fill": "#11224f",
"gradient": "none",
"stroke": "#11224f",
"strokeWidth": 0.0024,
"strokeStyle": "solid",
"radius": 0
},
{
"id": "p-b5dnke1",
"frame": {
"x": 0.0238,
"y": 0.0238,
"w": 1.9524,
"h": 0.3062
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "photo",
"photoId": null,
"fit": "cover",
"focalX": 0.5,
"focalY": 0.5,
"radius": 0,
"filter": "none",
"mask": null,
"frameStyle": "none"
},
{
"id": "t-kvtn36a",
"frame": {
"x": 0.0762,
"y": 0.3843,
"w": 0.8476,
"h": 0.0479
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "text",
"text": "TEST1",
"font": "sans",
"size": 0.1048,
"weight": 700,
"italic": false,
"align": "left",
"leading": 1.1,
"tracking": -0.02,
"color": "#1a1a1a",
"binding": {
"source": "entry.title",
"entryId": 62
},
"overridden": false
},
{
"id": "t-cnhlrmk",
"frame": {
"x": 0.0762,
"y": 0.452,
"w": 0.8476,
"h": 0.1917
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "text",
"text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.",
"font": "sans",
"size": 0.0476,
"weight": 400,
"italic": false,
"align": "left",
"leading": 1.55,
"tracking": 0,
"color": "#1a1a1a",
"binding": {
"source": "entry.story",
"entryId": 62
},
"overridden": false
},
{
"id": "li-w82p1lh",
"frame": {
"x": 1.1161,
"y": 0.6769,
"w": 0.7837,
"h": 0.0731
},
"rotation": 0,
"opacity": 1,
"locked": false,
"font": "sans",
"color": "#ffffff",
"accent": "#ffffff",
"textScale": 1,
"weight": 700,
"stale": false,
"kind": "list",
"items": [
{
"text": "TEST1",
"tone": "pro"
},
{
"text": "TEST1",
"tone": "con"
}
],
"layout": "columns",
"showMarks": true,
"proLabel": "PROS",
"conLabel": "Cons"
},
{
"id": "t-gabkf9x",
"frame": {
"x": 1.0762,
"y": 0.3843,
"w": 0.8476,
"h": 0.1917
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "text",
"text": "Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet. Lorem ipsum dolor sit amet, consetetur sadipscing elitr, sed diam nonumy eirmod tempor invidunt ut labore et dolore magna aliquyam erat, sed diam voluptua. At vero eos et accusam et justo duo dolores et ea rebum. Stet clita kasd gubergren, no sea takimata sanctus est Lorem ipsum dolor sit amet.",
"font": "sans",
"size": 0.0476,
"weight": 400,
"italic": false,
"align": "left",
"leading": 1.55,
"tracking": 0,
"color": "#1a1a1a",
"binding": {
"source": "entry.story",
"entryId": 62
},
"overridden": false
},
{
"id": "s-vkl7noe",
"frame": {
"x": 1.0762,
"y": 0.6034,
"w": 0.5,
"h": 0.0024
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "shape",
"shape": "rect",
"fill": "#141414",
"gradient": "none",
"stroke": null,
"strokeWidth": 0,
"strokeStyle": "solid",
"radius": 0
},
{
"id": "p-s3anbzj",
"frame": {
"x": 0.1836,
"y": 0.7134,
"w": 0.2,
"h": 0.2171
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "photo",
"photoId": null,
"fit": "cover",
"focalX": 0.5,
"focalY": 0.5,
"radius": 0,
"filter": "none",
"mask": null,
"frameStyle": "polaroid"
},
{
"id": "p-874qk6j",
"frame": {
"x": 0.5893,
"y": 0.7134,
"w": 0.2,
"h": 0.2171
},
"rotation": 0,
"opacity": 1,
"locked": false,
"kind": "photo",
"photoId": null,
"fit": "cover",
"focalX": 0.5,
"focalY": 0.5,
"radius": 0,
"filter": "none",
"mask": null,
"frameStyle": "polaroid"
},
{
"id": "b-ga9zv0w",
"frame": {
"x": 0.0399,
"y": 0.041,
"w": 0.17,
"h": 0.062
},
"rotation": 0,
"opacity": 1,
"locked": false,
"font": "sans",
"color": "#1a1a1a",
"accent": "#111111",
"textScale": 1,
"weight": 700,
"stale": false,
"kind": "badge",
"autoColor": true,
"variant": "day",
"text": "DAY 1",
"sub": "",
"code": null,
"style": "chip"
},
{
"id": "b-gmtenyt",
"frame": {
"x": 0.5993,
"y": 0.8893,
"w": 0.18,
"h": 0.05
},
"rotation": 0,
"opacity": 1,
"locked": false,
"font": "sans",
"color": "#1a1a1a",
"accent": "#111111",
"textScale": 1.2,
"weight": 700,
"stale": false,
"kind": "badge",
"autoColor": true,
"variant": "coords",
"text": "51°10'N 10°27'E",
"sub": "",
"code": null,
"style": "plain"
},
{
"id": "b-4rd95kg",
"frame": {
"x": 0.1936,
"y": 0.8893,
"w": 0.18,
"h": 0.05
},
"rotation": 0,
"opacity": 1,
"locked": false,
"font": "sans",
"color": "#1a1a1a",
"accent": "#111111",
"textScale": 1.2,
"weight": 700,
"stale": false,
"kind": "badge",
"autoColor": true,
"variant": "coords",
"text": "51°10'N 10°27'E",
"sub": "",
"code": null,
"style": "plain"
}
],
},
]
@@ -0,0 +1,267 @@
/**
* GENERATED — do not edit. Run `node scripts/build-country-shapes.mjs`.
*
* Country silhouettes, simplified from the admin-0 boundaries the server
* bundles for Atlas (geoBoundaries, CC BY 4.0). Each path is Web Mercator,
* normalised so the longer side is 100 units; `w` and `h` give the shape's
* own proportions inside that box so it can be drawn without being squashed.
*
* 198 countries, built for print at postage-stamp size — islands
* below 1.2% of the mainland are dropped and each outline is
* simplified to at most 190 points.
*/
export interface CountryShape {
/** Path on a 0..100 box, outer rings only. */
d: string
/** The shape's own width and height inside that box. */
w: number
h: number
/** Projected bounds `[minX, minY, maxX, maxY]`, for drawing it in place. */
b: [number, number, number, number]
}
/**
* Web Mercator, matching the projection the paths were built with. Longitude
* passes through as degrees; latitude is flipped so y grows downwards like
* SVG's does.
*/
export function projectMercator(lng: number, lat: number): { x: number; y: number } {
const clamped = Math.max(-85.05, Math.min(85.05, lat))
const rad = (clamped * Math.PI) / 180
return { x: lng, y: -(Math.log(Math.tan(Math.PI / 4 + rad / 2)) * 180) / Math.PI }
}
/**
* The inverse, for handing the vector map's extent to something that thinks in
* coordinates — the tile grid, which has to cover exactly what the outlines
* cover or the picture and the shape it is cut to will not line up.
*/
export function unprojectMercator(x: number, y: number): { lng: number; lat: number } {
const rad = 2 * (Math.atan(Math.exp((-y * Math.PI) / 180)) - Math.PI / 4)
return { lng: x, lat: (rad * 180) / Math.PI }
}
/**
* The same outline, placed in projected world coordinates.
*
* The stored path is normalised into its own box; multiplying by the box's span
* and shifting by its origin puts it back where the country actually is, which
* is what a map with more than one country on it needs.
*/
export function countryWorldPath(shape: CountryShape): string {
const span = Math.max(shape.b[2] - shape.b[0], shape.b[3] - shape.b[1]) / 100
let axis = 0
return shape.d.replace(/-?\d*\.?\d+/g, m => {
const isX = axis++ % 2 === 0
const v = Number(m) * span + (isX ? shape.b[0] : shape.b[1])
return String(Math.round(v * 1000) / 1000)
})
}
export const COUNTRY_SHAPES: Record<string, CountryShape> = {
AD: { d: 'M84.2 57.2L84.2 50L81.6 50L84.2 46.5L84.2 42.9L86.8 42.9L86.8 35.8L92.1 35.8L92.1 32.2L92.1 35.8L94.7 32.2L94.7 35.8L94.7 32.2L100 32.2L97.4 28.6L89.5 28.6L86.8 25L84.2 25L84.2 17.9L86.8 17.9L86.8 14.3L84.2 14.3L84.2 17.9L84.2 14.3L81.6 14.3L78.9 17.9L78.9 14.3L65.8 14.3L63.2 10.7L44.7 10.7L44.7 3.6L36.8 3.6L36.8 0L34.2 3.6L18.4 3.6L18.4 7.2L15.8 7.2L15.8 14.3L18.4 14.3L18.4 17.9L13.2 17.9L13.2 21.5L7.9 21.5L7.9 25L5.3 25L5.3 28.6L7.9 32.2L7.9 35.8L2.6 35.8L2.6 42.9L0 42.9L10.5 42.9L10.5 46.5L13.2 46.5L13.2 50L15.8 53.6L13.2 53.6L13.2 57.2L7.9 57.2L5.3 60.8L2.6 60.8L2.6 64.3L7.9 64.3L7.9 71.5L10.5 71.5L7.9 71.5L7.9 75L10.5 75L10.5 78.6L7.9 78.6L18.4 78.6L18.4 82.2L36.8 82.2L36.8 78.6L39.5 75L39.5 71.5L42.1 71.5L42.1 75L44.7 75L44.7 71.5L47.4 71.5L50 67.9L52.6 71.5L52.6 67.9L55.3 67.9L57.9 71.5L57.9 67.9L65.8 67.9L65.8 60.8L68.4 57.2L71.1 57.2L71.1 60.8L73.7 57.2L73.7 60.8L78.9 60.8L84.2 57.2Z', w: 100, h: 82.2, b: [1.41, -47.25, 1.79, -46.94] },
AE: { d: 'M99.8 25.1L99.6 11.1L95.6 9.9L95 5.5L96 2.1L95 0L93.8 0.5L92.3 4.2L93.1 3.2L93.3 3.9L91.1 5.8L91.3 6.9L91.1 6L89.4 8.1L87.5 7.9L87.7 8.8L86.9 9.5L86.7 8.6L86.7 9.9L84.4 12.5L83 12.5L83.4 10.6L81.5 13.6L82.1 13.9L80.5 14.8L81.3 14.5L81.1 15.5L80.5 14.8L79.4 17.1L79 16.2L78.8 17.8L77.3 16.4L78.2 20.3L78.2 18.9L76.7 18.2L77.1 18.9L76.5 18.5L76.9 19.1L74.4 22.8L72.1 24.2L72.6 25.3L71.3 23.7L71.7 25.1L71.3 24L70.7 25.1L70.9 23.7L70.3 25.1L71.1 25.3L69.6 26.3L69.2 25.6L69.6 26.5L68.2 26.9L69.2 28.1L68 26.9L66.3 27.9L66.9 29.2L66.1 29.9L66.3 27.6L65.3 28.8L66.1 29.2L64.9 29L64.7 30.6L63.6 31.1L64.9 32.4L63 35.4L63.8 36.3L60.7 37.7L61.3 38.8L60.1 39.3L59.9 40.2L61.1 40.4L55.5 42.3L53.2 40L52.4 41.6L53.4 42.5L50.9 43.2L52 43.9L53.2 42.5L54.1 42.9L52.6 44.5L52 43.9L52 44.8L48.6 44.8L48.9 45.9L42 46.6L39.1 44.5L36.2 44.8L36.2 45.5L35.3 44.3L35.6 45.2L34.1 44.5L34.5 45.9L32.8 46.1L32 43.9L31.2 44.8L30.4 44.5L31 43.9L24.5 44.5L23.7 43.4L22.9 44.5L21.2 42.5L20.8 44.5L18.7 45.5L17.9 44.8L18.1 46.1L14.8 48L10.8 48.9L7.9 47.5L7.5 48.6L4.4 46.8L4.4 41.4L3.5 40.9L2.5 42.7L1.7 40L2.1 42.3L1.2 42.9L1.5 40.2L0 38.6L0.4 44.5L21 71.5L74.2 78.5L76.1 75.1L76.1 67.7L83.2 56.4L82.5 51.1L81.3 48.9L86.5 46.1L88.6 47.3L92.5 45.9L91.3 42.5L86.9 42.3L88.6 40.2L88.6 37.9L87.3 35.9L88.4 33.4L87.7 32.9L88.8 32.2L88.1 26.7L91.3 24.4L93.1 25.3L93.3 27.6L91.7 27.6L94.4 30.6L96.3 29.7L96.5 28.1L98.5 27.4L99.4 26.3L98.8 25.3L99.8 25.1Z', w: 100, h: 78.5, b: [51.57, -27.02, 56.38, -23.24] },
AF: { d: 'M100 11.1L99.4 10L97.1 9.4L95.5 9.4L95.1 10.2L92.1 11.2L91.2 10.7L92.2 10.1L92.3 9.3L89 9L85.5 11.1L84.6 12.9L82 13.3L78.8 15.9L77.6 16L75.9 12.7L77 5L74.7 5L75.5 2L73.7 0.6L73.3 0.8L72.8 0L72.4 0.5L72 0.2L70.6 0.7L69.5 3.3L67.8 4.5L67.9 4.9L67.2 4.8L67.2 5.7L68.1 6.4L67.8 7.8L67 8.6L65.4 7.7L64.7 8.1L62.7 8L61.6 9.5L62.1 11.2L60.8 12.3L58.2 10.2L58.5 10.8L58 10.3L57.8 11L56.8 10.7L56.6 11.3L54.1 12.2L53.9 13L52.2 13.8L51.4 12.5L50.6 12.3L50.3 11.3L49.1 11.2L48.7 10.7L46.9 11.5L45.4 9.8L40.3 10.3L38.7 9.2L36 8.4L34.9 11L32.1 10.9L29.5 12.2L29.8 13.8L28.5 16.2L28.7 18L27.9 19.2L26.2 20.5L24.8 20.7L24.7 21.8L24.1 21.4L21 22.6L18.1 23L19 24.5L17.9 25L18 26.7L15.5 28.2L14.6 28.5L13.6 27.9L12.4 29.2L12.2 27.8L10.6 26.6L7.5 26.7L5.9 24.9L5.2 25.1L4.7 27.9L4 28L4.4 29.6L3.3 33.4L1.5 34.3L2.8 36.2L1.1 36.2L0 37.8L0.3 41.9L3 42.9L2.3 43.1L2.4 43.6L0.4 46L2.5 53.7L2 57.7L2.4 59.5L8.3 60.4L9.2 63.3L9 64.8L2.4 72.7L13.4 76.5L21.3 75.7L24.8 76.6L26 75.4L28 74.9L31.6 75.4L39.9 72.8L40.7 71.9L39.7 71L40.7 68.2L40.1 67L40.8 64L42.2 63.7L43.2 61.8L44.7 61L45.4 61L45.6 61.8L46.3 61.5L47.5 61.9L50.5 60.9L50.6 60.3L49.3 60.2L49.1 59.2L50.1 59.4L51.1 58.4L51.9 58.3L53.2 56.7L55.8 57.7L56.2 57.4L55 57.3L56.1 56.8L58 58.7L59.8 57.8L60.3 56.6L61.3 55.9L60.7 51.5L62.1 50L61.7 48.9L62.8 48.1L62.5 46.9L63 46.3L65.5 46.3L68.3 44.4L67.2 42.8L66.9 41L66 41L65 39.4L65.3 38.4L69.8 39.3L72.1 39L73.5 38.3L74.2 35.7L73.1 35L73.1 34L75.1 31.3L76.5 30.6L76.6 29.6L77.7 28.6L76.7 27.7L77.5 26.6L77 26.1L77.2 25.5L76.4 24.9L76.8 24.1L75.5 22L74.3 21.3L77 18.9L77.4 17.6L78.6 18.3L78.6 17.4L80.5 16.7L80.5 16.2L81.4 16.1L81.2 15.5L84.3 14.4L92.8 13.7L93.7 14.6L94.6 14.5L96.7 13L97.6 13.5L97.8 12.9L97 12L97.3 11L98.4 11.1L99 10.6L99.4 11.3L100 11.1Z', w: 100, h: 76.6, b: [60.52, -41.76, 74.89, -30.76] },
AG: { d: 'M10.5 76.7L7.8 76.7L7.8 78.1L6.5 79.5L6.5 82.2L7.8 82.2L6.5 82.2L7.8 82.2L6.5 82.2L7.8 82.2L7.8 83.6L9.2 83.6L5.2 83.6L5.2 82.2L2.6 82.2L2.6 83.6L1.3 83.6L1.3 86.3L3.9 86.3L3.9 85L5.2 85L5.2 86.3L3.9 87.7L1.3 87.7L0 89.1L2.6 89.1L2.6 91.8L1.3 93.2L1.3 94.5L2.6 94.5L2.6 95.9L3.9 97.3L9.2 97.3L9.2 98.6L10.5 97.3L10.5 100L10.5 98.6L17 98.6L15.7 98.6L17 97.3L18.3 97.3L18.3 98.6L17 98.6L17 100L22.2 100L22.2 98.6L22.2 100L23.5 100L23.5 98.6L22.2 98.6L22.2 97.3L23.5 97.3L23.5 98.6L23.5 97.3L22.2 95.9L22.2 94.5L24.8 94.5L24.8 95.9L28.8 95.9L30.1 94.5L28.8 94.5L30.1 94.5L30.1 93.2L30.1 94.5L31.4 94.5L31.4 93.2L30.1 93.2L31.4 93.2L30.1 91.8L31.4 91.8L30.1 91.8L30.1 90.4L27.5 90.4L27.5 89.1L27.5 90.4L26.1 90.4L28.8 87.7L28.8 89.1L30.1 89.1L30.1 86.3L26.1 86.3L24.8 87.7L24.8 86.3L26.1 86.3L23.5 86.3L23.5 87.7L23.5 85L20.9 85L20.9 82.2L22.2 82.2L20.9 80.9L20.9 82.2L19.6 82.2L19.6 85L18.3 85L18.3 83.6L18.3 85L17 85L17 83.6L18.3 83.6L17 83.6L17 82.2L17 83.6L15.7 83.6L15.7 82.2L17 82.2L17 80.9L18.3 80.9L17 80.9L15.7 79.5L15.7 78.1L14.4 76.7L10.5 76.7ZM7.8 0L6.5 0L6.5 2.7L5.2 2.7L5.2 4.1L6.5 4.1L6.5 5.5L7.8 5.5L7.8 6.9L9.2 6.9L9.2 11L10.5 11L10.5 12.4L9.2 12.4L9.2 15.1L10.5 15.1L9.2 15.1L9.2 16.5L6.5 16.5L7.8 15.1L7.8 9.6L6.5 9.6L6.5 6.9L5.2 6.9L6.5 6.9L5.2 5.5L6.5 4.1L5.2 4.1L5.2 1.4L3.9 2.7L3.9 4.1L2.6 4.1L2.6 5.5L3.9 5.5L3.9 6.9L5.2 6.9L5.2 8.2L6.5 11L6.5 19.2L5.2 19.2L5.2 20.6L6.5 20.6L7.8 19.2L11.8 19.2L11.8 20.6L13.1 20.6L14.4 22L17 22L17 23.3L18.3 23.3L18.3 26.1L19.6 24.7L22.2 24.7L22.2 26.1L23.5 26.1L22.2 24.7L22.2 19.2L23.5 19.2L23.5 16.5L22.2 16.5L22.2 12.4L20.9 12.4L20.9 11L17 6.9L17 5.5L15.7 5.5L14.4 4.1L11.8 4.1L11.8 5.5L9.2 5.5L9.2 2.7L10.5 2.7L10.5 4.1L11.8 4.1L10.5 2.7L10.5 1.4L7.8 0Z', w: 31.4, h: 100, b: [-61.91, -18.02, -61.67, -17.26] },
AL: { d: 'M19.7 3.4L17.9 4.1L17.9 5.1L16 4.7L15.2 6.1L14.7 5.4L13 6.4L11.2 4.1L11.5 2.7L12.2 2.7L11.5 1.7L11.7 0.7L10.2 0L8 2.7L8.2 4.1L7 4.7L4.2 9.5L3.2 9.8L2.5 12.8L0 15.9L3 18.2L2 20.6L2.5 23.6L1.5 23.6L2 24.9L1.2 25.6L2.2 25.9L2 27.3L4.5 26.9L7 28.6L7.5 28.3L7.7 30L7 30L7.5 30.6L7 30.6L6.7 34L7.5 33.3L7 34.3L7.7 35L7.5 34L8 34.6L6.5 36.6L3.7 36L4.2 37.3L5.7 38L5.5 39.6L3.7 41L3.7 42.3L2.5 42L3.2 42.6L3.2 44.9L4 45.6L5 45.3L5.7 46.6L4.5 50.6L3.7 50.6L4.2 51.9L3.7 55.9L4.5 56.2L3.7 55.5L4.5 55.5L4.7 56.2L3.7 57.8L4.2 57.2L4.2 58.2L4.7 57.5L5.2 55.5L6 58.5L3.7 59.8L4.2 58.2L3 58.2L2.7 60.5L2 61.1L2 65.1L0.5 66.7L1.2 66.7L0.7 67.1L1.2 68.1L1.5 67.4L1.2 68.4L2.5 70.7L2.5 69.4L4.2 70L3.7 72.3L2.2 71L5 73.6L4.7 77.2L3.2 77.9L2.2 74.9L0.7 74L0 74.6L3.7 80.8L11.5 85.1L12.5 86.7L14 86.7L15.2 88.3L16.2 90.3L15.5 91.6L16.5 91.6L18.2 93.2L17.7 95.8L16.9 96.1L17.2 96.8L17.7 96.4L17.2 98.4L18.4 98.1L22.7 100L23.4 99.7L23.4 98.4L24.2 99L25.7 97.1L24.9 96.1L25.2 94.2L27.2 95.5L28.2 93.8L25.4 88.6L27.4 88.6L28.4 86L29.9 86.4L30.9 85.7L31.9 86.4L34.6 85.1L34.6 82.1L35.6 81.5L35.4 79.2L37.4 76.6L37.4 74.3L38.6 72.6L41.6 73L41.6 71.7L43.9 69.7L44.1 66.4L41.6 63.1L42.1 58.8L38.9 57.5L38.4 58.8L35.9 58.5L34.4 52.6L32.4 52.6L32.4 50.6L30.7 48.3L29.9 44.3L30.9 44.3L31.7 41.6L30.4 41L30.2 39.3L28.9 38.3L29.2 37L31.7 36.3L30.7 34.3L30.4 31.3L30.9 29.6L31.9 29.3L31.7 26.6L32.4 26.3L31.9 24.9L33.4 23.6L32.7 20.9L31.4 19.6L31.9 17.9L30.9 15.2L29.4 13.2L26.4 11.8L26.4 11.2L25.4 11.8L23.9 11.5L23.9 9.8L22.9 8.8L23.2 7.4L22.4 7.1L21.9 5.1L20.2 4.4L19.7 3.4Z', w: 44.1, h: 100, b: [19.29, -47.25, 21.06, -43.24] },
AM: { d: 'M48.8 0L47 1.7L42.3 0L42 2.1L45.1 3.3L44.8 4.1L42 2.9L39.5 4.1L39.8 2.9L38.9 3.7L38.6 2.5L36.1 2.5L35.8 4.6L33.9 4.6L33.6 2.9L31.4 5L28.6 2.9L27.1 4.1L22.7 2.1L22.4 4.6L11.8 5.8L9.6 7.9L6.8 6.6L0.3 7.4L0.9 11.6L7.2 15.3L7.2 19L9.3 23.1L6.2 31.7L2.8 33.7L5.3 35.8L4.4 39.5L7.2 42.7L6.2 44.3L8.4 46.8L6.2 47.2L6.2 48.8L14 52.5L24.3 51.3L28 52.1L32 54.5L35.8 60.6L38.6 61.4L40.4 65L44.5 63.4L47.3 64.2L50.7 61.8L54.1 66.3L53.5 69.9L57.9 69.1L57.9 71.5L61.9 72.3L62.8 73.9L65.9 70.7L71.8 69.9L74.3 74.7L72.8 78.8L79.3 82L78.7 86L82.4 92.8L83.7 100L89.9 96.8L95.8 98.8L95.2 95.2L96.4 91.2L92.7 86L95.2 86.4L95.5 85.2L96.1 87.2L97.7 85.6L97 84.8L98.9 84.4L96.4 83.6L94.9 79.6L93.9 80.4L92.1 79.2L91.1 75.9L96.1 74.7L94.9 72.7L96.1 71.5L97.7 72.3L97.4 71.1L94.9 69.9L92.7 70.7L92.4 68.7L90.8 67.9L84.9 70.3L84.3 67.5L81.5 66.3L78.7 61.8L74 60.6L73.1 55.7L67.2 54.5L67.2 52.5L73.1 52.1L74 53.7L75.6 52.5L76.2 49.2L78.1 48.4L78.7 43.9L77.1 43.1L78.1 41.9L68.4 38.2L66.9 35.8L62.2 32.9L62.5 29.6L59.4 26.8L59.7 24.7L65.9 21L66.9 16.9L63.1 15.7L62.5 13.6L61.3 14L61.9 11.6L57.9 12.4L56.9 10.7L56.3 12.4L52.3 8.3L51.9 10.3L50.1 10.3L51 7.4L53.8 7.4L54.4 5.4L51.9 3.7L51 5L48.8 4.1L50.1 2.5L48.8 0Z', w: 98.9, h: 100, b: [43.45, -45.43, 46.63, -42.21] },
AO: { d: 'M98.1 40.9L97 42.1L93.6 41.3L91.7 42.7L88.9 42.3L88.2 42.8L86.1 42.1L85.6 43.3L84.1 43.9L83.5 40.6L84.8 39.9L84.3 37.8L84.8 36.8L83.8 35L83.6 33L82.7 32.7L81.1 30.8L80.4 29L81.7 21L80.1 17.1L81 12.8L80 11.7L70.5 11.7L71.2 8.8L68.7 8.8L68.5 9.4L62.5 9.4L62.1 11.5L62.5 13.1L62 14L61.1 14.1L61.2 17.5L56.5 17.5L56.3 16.8L54.4 16.8L54.4 17.5L51.7 17.3L50.9 17.7L50.9 18.3L46.6 18.5L44.5 14.5L43.3 13.3L43.6 12.8L42.9 12.8L43 12.2L41.7 11.3L41.9 9.8L41.3 9.7L41.4 8.6L40.9 8.5L39.6 4.5L40 2.7L38.9 1.8L38.9 0.4L38.4 0.2L21.4 0.6L18.2 0L13.6 0.2L13 0.7L11.7 0.3L10.1 0.6L8.6 1.8L6.2 2.1L7.3 2.6L5.9 2.1L5 2.3L5.3 2.8L4.8 2.3L4.7 2.7L4.6 2.1L4.3 2.5L6.7 6.8L8.7 9L8.4 8.9L9 11.5L13.4 20.7L13 21.2L13.4 23.5L12 23.8L11.4 25.2L10.2 26.4L11.3 24.4L10.1 26.3L11.6 28.9L12.1 30.8L11.7 31.1L12.6 32.5L12.6 33.5L14.4 35.9L14.4 36.8L16.3 39L15.9 39.9L17.1 42L16.4 49.1L15.4 52L14.3 52.9L13.8 54.3L13 55L11.8 54.8L9.7 56.8L10 58.1L6.4 61.6L6.4 65.1L5.5 65.4L5 66.9L5 69.8L4.5 72.7L3.1 75.4L3.4 76L2.6 76.6L2.2 79.9L0 81.9L0.6 83.1L0.8 86.7L0.2 93.4L2.8 92.5L4 93.3L7 93.3L10 91.1L11.5 90.9L13.9 91.4L14.2 92.5L17.8 94.9L53.5 94.5L55.1 96.6L57.3 98.2L63.6 98.4L64.1 98.9L68.8 98.5L70.5 99.6L72.5 100L75.1 99.1L77.6 99.9L93.6 96.6L91.2 95.2L90.1 93.5L88.5 92.7L86.1 89.7L83.1 87L83.2 86L82.1 84.7L82.1 58.2L98.6 58.2L97.2 56.8L97.7 54.2L98.8 53.1L98 51.4L98.3 48.5L97.8 47.2L98.3 45.5L98.8 45.1L98.1 40.9Z', w: 98.8, h: 100, b: [11.73, 5.84, 24.09, 18.35] },
AQ: { d: 'M86.2 0L83.7 2.2L83.9 5.7L83.3 6.6L81.4 7.2L80.6 6.4L80.2 7.2L78.6 6.1L78 6.7L75.1 6.2L76.2 6.9L75.2 7L76.2 8.5L73.1 8.4L73.1 7.4L72.5 8.3L72.5 7.2L70.6 8L67.3 7.8L66.1 9L64.7 9.1L64.9 9.6L63.9 9.5L64.9 9.9L64.9 10.8L64.1 10.3L63.8 11.1L61.8 10.3L61.8 11.3L62.9 11.6L62.3 12.1L64.5 13.7L63.6 14.4L64.3 15.3L62.1 15.7L63.2 18L61.6 19.9L60.3 19.9L60 19.1L59.1 19.4L57.9 18.3L60.4 20.7L60.7 21.8L59.9 21.9L62.5 23L62 23.2L62.5 23.6L59.6 23.1L56.9 21.6L56.6 23.6L100 23.6L13.1 23.6L56.6 23.6L56.4 21.4L53.7 19.5L53 17.6L51.9 17.8L52.7 17.3L51.8 15.9L51.9 14.4L51.3 14.5L52.1 13.7L51.6 13.6L52.1 11.9L52.4 12.5L53.5 12.1L52.7 11.6L51.9 8.6L52.6 7.6L53.1 7.9L52.8 6.7L53.1 7.2L53.3 6.4L54 6.7L54.2 5.1L52.4 4.3L52.3 5L50.3 3L49.7 3.4L45.8 1.6L40.6 2.5L41.1 2L40.5 1.4L39.4 2.2L37.9 1.5L32.2 2.9L29.3 6.7L29.9 2.7L26.7 2.4L26.9 1.9L26.1 1.5L24.8 2.8L24.4 2.3L22.7 3.4L22.5 4.3L21.2 3.2L21 4.2L19.7 4.9L15.3 4.2L13.1 5.2L100 5.3L98.5 5.2L98.6 4.6L98 5.4L97.1 5.1L97.3 6L96.2 6.6L96.5 7.2L95.6 8.6L93 9.5L91.3 11.5L91.8 12.7L92.7 12.6L92.6 13.3L94.6 13.7L90.6 15.1L89 17.9L88.9 16.8L86.9 17.3L85.6 19.8L84.9 17.9L85.4 17.2L84.1 17.5L84.7 16.7L84.1 16.5L85 16.2L82.8 14.7L81.8 15.1L81.6 14L80.8 13.8L81.7 13.1L80.5 13.2L79.8 11.3L81.2 12.3L80.3 11.1L81.9 11.6L82.5 10.8L81.3 9.7L83.1 9.9L85.3 7.7L85.3 5L84.2 2.4L86.2 0ZM1.7 11L0.1 15.1L2.6 14.1L2.6 11.6L1.7 11Z', w: 100, h: 23.6, b: [-54.41, 82.21, 359.97, 179.99] },
AR: { d: 'M24.9 1.1L24.5 0.5L21.9 0.5L21.1 2.7L20.6 1.2L19.5 0.7L17.8 0.8L16.7 0L16.6 0.8L15.5 1.1L14.5 2.5L15 3L14.2 5.6L11.5 7L11.9 8.3L11.3 9.1L11.8 11L11.3 11.8L12 13.2L10.8 13.4L8.1 19.1L8.5 21.3L8.2 21.9L7.8 21.7L6.8 25.1L7.6 26L7.2 26.3L8.1 28.6L8 29.5L8.6 29.8L8.6 32.3L8.1 32.3L6.8 35L7.4 36.6L7.2 37.6L5.7 38.5L5.3 39.9L5.4 42.5L6.2 44.6L4.8 45.6L4.8 47.5L4.2 47.6L4.5 48.4L3.9 49.4L4.3 49.8L3.6 50.9L4.2 55.1L3.1 55.3L3.5 56.2L3.2 57.6L4.2 58.4L3.7 59.3L4.5 59.9L3.9 62.1L5.6 62.7L5.2 63.6L3.4 63.5L5.1 65.1L4.1 66L4.4 67.4L3.7 67.9L4.4 69.7L3.6 70.1L3.9 71.5L2.4 73.2L3 75.2L2.2 75.8L2.3 76.9L1.4 77.3L1.2 78.5L0.2 78.5L0 79.3L0.8 84L1.9 83.2L2.9 83.4L2.6 86.8L3.7 88.3L8.2 88.3L11.9 89.6L10.5 86.9L9 86.9L10.5 86.7L10 84.6L9.4 85L10.1 84.3L10.3 82.7L11.8 81.7L10.8 81L11.4 80.9L11 80.2L11.9 81.5L12.9 81.1L13.6 77.7L17.7 73.9L17.5 73.3L17 73.4L17.6 73.3L17.8 71.4L15.4 70.7L13.9 69.2L13.5 67.6L15.1 65L18.3 64L17.8 63.6L19 62.2L18.7 59.9L19.3 58.8L21.1 57.8L19.5 57.2L19.5 56.8L21 56.4L21.6 57.5L22.7 56.7L22.3 55L20.9 55.5L21.7 55.9L20.8 56.1L20.4 56L20.7 55.5L19.5 55.1L19.2 51.3L20 50.9L19.7 51.2L22.3 52.2L23.8 52.2L25.6 51.4L25.9 50.6L25.2 49.6L26.2 47.1L25.6 46.7L26.3 46.9L25.6 46.5L25.6 45.1L26.8 45.8L29 45.7L33.3 44.8L36.5 43.2L38.5 39.2L38.3 38L37.7 38.1L36.9 37.2L37.3 35.2L34.6 33.4L34 32.3L34.7 32.1L34.6 31.6L34.1 31.7L34.5 31.5L34.2 30.2L34.4 29L35.2 28.8L35 25.8L35.9 23.3L35.7 22.2L40.3 16.6L40.7 16.7L40.5 16.3L45.3 12.9L45.4 11.1L44.3 9.3L43.1 9.7L42.8 12.1L41 13.3L40.6 14.2L39.8 13.9L39 14.6L38.6 14.2L34.1 13.8L35.1 11L36.4 9.4L36 8.4L30.8 5.5L28.6 5L24.9 1.1ZM11.3 99.4L12.1 99L16.2 100L17.2 99.4L18.6 99.5L19.3 98.4L16.8 98.1L13.2 95.2L12.3 93.3L11.4 93L11.8 92.1L12.2 92.6L11.3 90.8L11.3 99.4Z', w: 45.4, h: 100, b: [-73.56, 22.32, -53.64, 66.22] },
AT: { d: 'M12.1 28.8L12.5 31.3L9.3 34.4L8.4 34.4L9.3 32.3L7.7 32.3L7.5 32.8L7.1 31.7L7.6 31.3L6.8 30.1L6.2 30.3L5.8 29L4.3 29.3L3.3 28L2.8 29.3L0.5 29.7L1.8 31.9L0 34.4L0.4 36.3L1.4 37.1L1.2 38.6L4.6 39.2L4.6 40.9L6.6 41.5L7.5 42.7L9.2 42.1L9.3 40.9L10.1 41.1L11.4 39.6L12.6 40.7L12.3 42.5L13.6 42.7L14.9 41.9L16.1 42.8L15.7 43.6L17.8 44.2L19.7 44L21.4 40.2L24.1 39.8L24.6 40.3L25.4 39.4L29.4 40.2L31.5 38.8L35.3 37.8L35.1 39L33.9 39.4L34.6 40.7L34.2 41.3L35.1 42.1L35.9 41.7L36 43.8L37 43.8L37.5 45.1L39.7 46.3L42.6 46.3L43.5 47.2L46.9 47.2L48.5 48.2L50.6 47.6L54.3 48.8L57.7 48.8L60.2 49.5L60.7 50.5L65.4 50.5L66.1 51.6L66.4 50.3L69.3 49L70 47L71.3 46.7L71.6 47.2L72.1 46.3L77.1 46.1L78.2 47L79 45.7L82.2 44.9L85.3 46.1L84.5 44.4L84.8 42.7L88.5 40.3L88.5 39.4L91.5 39.6L90.6 39L91.6 38.6L90.8 37.7L91.7 37.1L90.7 36.9L90.3 35.9L91.2 34.2L90.4 32.8L90.7 31.7L92.4 31.7L94.1 29.5L93.4 27.6L90.3 26.6L92.5 24.9L94.2 25.3L94.6 26.4L95.7 26.4L96.1 25.6L96.2 26.2L99.1 25.8L98 22.7L99.1 22.7L99.1 20.8L100 20L98.8 19.6L99 17.8L97.6 16.8L96.7 13.7L95.8 13.3L96.1 10.9L97.2 9.7L96.7 6L93.7 5.8L93.6 4.8L92 4.2L90.8 4.2L89.9 5.8L85.6 5.4L82.8 2.8L80.6 3.2L75.5 0.6L75.1 1.4L73.8 1.6L73.7 0.4L71.7 0L71.4 5L69.2 4.8L69.2 5.8L67.9 7.2L67.9 8.8L64.7 7.4L63 9.3L59.5 8.6L58.7 7.6L59.4 6.8L56.5 5L56 6L56.4 8L55 10.1L52 8.6L51.2 9.2L51 12.7L49.8 13.9L46.4 14.7L42.3 17.6L42.2 18.6L45.5 22.9L44.2 25.6L46 25.6L46.8 27.2L45.6 30.7L42.9 29L42.6 26.6L40.6 26.4L40 27.4L38.8 27.4L38.1 26L35.6 26.4L35.8 25.3L34.5 26L34.9 28L32.5 27.4L30.4 28.4L27.5 28.2L27 29.5L25 29.5L24.2 30.5L24.8 31.1L23.6 30.9L23.1 31.9L21.6 31.3L19.3 32.1L18.5 30.3L17.6 30.3L18.2 29.7L17.8 29.1L16.4 29.7L15.2 28.8L14 28.6L13.6 29.3L12.1 28.8Z', w: 100, h: 51.6, b: [9.53, -56.4, 17.16, -52.46] },
AU: { d: 'M72.6 0L70.2 4.7L71.2 4.8L69.9 8L70.4 11L67.8 17.6L65.3 17.5L55 10.8L56.6 7.5L56.2 6.5L57.6 6.5L58.9 4.2L57.8 3L56.5 4.6L56.5 3.5L55.6 3.8L56.2 3.2L54.5 4.1L53.3 3.2L51.9 3.7L51.3 2.6L50.3 3L46.5 1.1L46 1.6L47.9 1.9L48.4 3.7L44.8 3.4L43.6 4.3L44 5L43 4.3L43.4 5.1L42.5 5L42.4 6.8L41.1 7.1L40.1 9.2L41.6 10.2L40.7 11.5L39.8 10.6L39.5 11.6L39.4 10.7L38 10.3L37.2 11.1L37.6 12.2L37 11.4L36.7 12.2L37.2 10.2L35.3 8.2L33.9 7.7L32.3 9L31.8 8.2L31.5 10.1L30.7 9L29.6 10.3L30.5 11.4L28.8 11.3L29.9 12.4L28.5 11.6L27.8 12.3L28 13.5L28.6 13L27.8 14.4L29.2 14.5L27.8 15L25.7 13.9L25.6 15.2L26.7 15.6L26.6 16.8L25.7 16.1L25.7 17.7L24.1 14.5L22.3 16.7L22.8 19L19.7 22.7L14.7 23.8L10.4 25.9L9 25.2L3.7 28.7L2.9 30.6L2.1 28.8L0.6 35.8L2.7 40.9L1.8 39.8L1.4 40.5L0.9 38.6L1.7 41.6L0.5 40L0.6 41.1L0 40.4L4.2 48.6L4.7 52.6L6.7 56.8L6.3 60.8L5.4 61.8L4.6 61.4L4.9 63.9L8.6 66L11.9 66.2L16.7 62.6L25.6 62.6L27.4 59.9L31.6 57.8L44.5 55.4L51.9 58.3L51.6 59.6L53.2 60.4L55.2 64.7L54.2 64.6L56.3 65.9L56.3 64.4L60.8 59.9L60.8 58.3L61.3 61.5L60 63.2L60 65.6L59 65.5L58.5 66.7L60.8 66.2L61.6 63.2L62.8 65.3L61.6 67.7L63.4 67.4L64.8 68.4L65.6 69.7L63.6 67.5L65.7 70.1L65.9 73L69.8 76.3L72.2 76.1L75.1 77.8L78.5 74.5L79 75.5L77.8 76L78.5 76.6L79.8 75.8L79.6 76.7L82.1 78.6L81.6 77.3L83.4 77L86 74.7L84.7 75.3L85.2 74.6L90.9 73.5L91.4 67.9L93.1 66.1L92.5 65.4L94.2 62.7L94 61.4L97.3 58.3L96.8 57.5L98.6 54.2L100 47.3L98.5 43.5L98.9 39.8L95.4 34.6L93 33.2L92.7 30.1L92.6 30.9L91.1 29.5L91.2 30.9L90.5 30.2L90.1 30.7L88 24.5L81.8 21L81 15.8L79.6 14.6L79.5 10.8L77.5 8.8L75.6 9.4L75.1 5.4L72.6 0ZM78 83.5L78 86L80 89.1L79.1 88.6L79.3 90L80.7 92.4L81.7 92.4L81.2 93.2L83.3 93.5L83.5 91.8L84.1 92.3L84.4 91.5L84.1 90.4L86.1 92.1L86.6 87.8L87 88.6L86.6 84.2L82.6 85.5L78 83.5Z', w: 100, h: 93.5, b: [113.15, 10.75, 153.64, 48.61] },
AZ: { d: 'M31.6 71.2L37.1 65L40.6 64.1L41.9 61.5L45.8 59.4L46.9 57.4L57.6 51.8L64.3 59.4L60.1 62L60.2 63.6L63.3 65.7L63.8 67.7L62.7 68.9L57.9 70.3L58.5 72.5L62.2 74.4L63.3 77.3L66 77.1L68.8 81.9L73.4 81L72.4 71.2L73.4 71.4L74.5 69.6L74.7 64.5L75.9 63.1L77.4 65.9L79.3 66.1L76.5 69.1L77.4 69.6L79.1 67.7L80.2 60.8L83.4 62L82.9 59.4L80.6 56.4L81.5 53.4L83.1 52.1L82.9 46.7L84.1 45.3L82.9 43.2L84.1 42.5L84 41.1L87.7 38.6L90.2 38.1L90.6 36.5L92.5 37.4L95 36.5L97.5 36.9L99.1 37.9L100 40.2L99.3 35.8L97.3 33.4L95.4 32.9L94.7 31.3L90 32L85.2 30.4L84.7 25.9L82.9 25.4L78.8 20.7L77.4 13.8L68.1 1.4L64.7 7.6L61.3 9.8L59 9.8L57.9 12.6L56.9 13.1L57.2 14.1L55.8 14.8L56.1 16L54 17.1L51 16L49.6 16.9L48.8 15.3L45.5 14.5L41.9 7.4L40.3 8.4L38.3 4.3L37.4 4.5L35.8 2.9L35.7 1.2L32.4 2.6L31.7 0.7L29.9 0L27.5 3.3L25.7 3.3L25.5 6.7L27.8 7.2L28.2 10L33 12.2L35.3 14.8L33.3 19.3L31.9 19L30.8 20.5L27.1 17.1L24.8 16.7L23.5 17.6L18.5 16.4L16.8 14.8L17.6 13.1L14.8 12.9L12.5 10.7L11.1 11.5L8.9 10.5L4.6 14.3L5.3 15.7L4.6 16.9L7.8 17.6L7.5 18.8L5.9 18.8L5.3 20.5L6.4 20.5L6.6 19.3L8.9 21.6L9.3 20.7L9.8 21.6L12.1 21.2L11.8 22.6L15 24.2L14.4 26.6L10.9 28.7L10.7 29.9L12.5 31.5L12.3 33.4L15.9 36.5L21.4 38.6L21.4 42.3L20.3 42.8L20 44.6L19.3 45.3L18.5 44.4L15.2 44.6L15.2 45.8L18.5 46.5L19.1 49.3L21.7 50L23.4 52.5L25 53.2L25.3 54.8L28.7 53.4L29.8 55.1L32.4 55.3L32.6 56L31 56.2L31.7 57.4L28.9 58.1L29.4 59.9L31 60.1L31.9 62.4L33.3 62.9L31.7 64.5L31.4 63.4L29.8 63.8L31.9 66.8L31.6 71.2ZM0.2 51.8L1.1 54.1L2.9 55.1L3.6 58.5L6.8 61.5L7 63.4L10.3 64.3L12.5 67L12.3 68.4L20.5 71.2L23.4 70.7L24.6 71.9L23.9 67.7L21.7 63.8L22.1 61.5L18.4 59.7L19.3 57.6L18.5 55.1L16.6 54.4L12.7 56.9L9.8 55.5L9.8 54.1L7.3 54.6L7.7 52.5L5.7 50L3.7 51.4L2.1 50.9L0.2 51.8Z', w: 100, h: 82.1, b: [44.76, -46.24, 50.37, -41.63] },
BA: { d: 'M84.6 15.3L80.2 15.7L77.9 13.8L77.6 12.4L79.2 12.4L78.9 10.6L77.1 10.6L76.9 8.7L75.6 8.4L75.8 6.9L75.3 8L74 8L74.6 6.6L74.3 7.3L73 6.6L73.3 7.7L72 6.9L72 8.7L69.4 6.2L66.6 6.6L64.3 5.1L63.8 7.3L61.2 7.3L60.4 5.1L58.1 4.7L57.6 6.2L56.6 6.2L56.8 7.3L54.2 8.7L50.1 5.5L45 6.2L45.2 5.1L44.2 5.5L44.2 4.4L43.2 5.5L41.4 5.1L39.8 3.3L39.3 4.7L36.8 4.7L33.7 1.8L32.9 2.6L33.2 1.5L32.4 2.2L31.6 1.1L32.1 1.8L31.1 1.8L31.1 0L30.3 1.1L29.6 0.7L28.8 2.9L28.3 2.2L28 3.7L22.6 1.8L19.8 2.2L19.3 4.4L17 6.2L15.9 10.2L11.8 9.1L11.3 7.3L9 6.2L6.7 1.8L1.8 2.6L0.8 4L1.5 6.2L0.3 8L1.5 10.2L0.3 12L1.5 16L0 16.4L0.3 17.5L2.6 20.7L4.4 19.3L8.5 24L7.5 26.1L10 27.6L11.3 31.5L10.3 32.6L12.6 33.7L11.8 34.8L12.6 38.4L11.3 38.7L12.9 38.7L15.2 42.3L18 43.4L18.3 45.2L19.8 45.2L21.1 48L25.2 51.2L26.2 54.5L27.8 54.8L32.9 61.6L36.5 64.4L39.6 64.8L40.1 66.2L39.1 67.6L41.1 72.2L42.9 72.9L43.7 75.4L48.8 78.2L50.9 82.4L47.6 83.8L48.6 84.8L46.8 84.1L49.4 85.6L49.9 84.5L51.7 84.5L52.7 85.6L53.5 84.5L54.2 84.8L55.5 88.4L58.4 89.4L64.5 95.7L67.9 95L69.7 97.1L71.2 96.1L72.2 96.8L71.7 95L73 94.3L73 91.5L70.4 90.5L69.9 88.4L71.5 85.6L71 82L72 80.6L75.3 80.3L75.1 75.7L76.9 73.6L76.1 72.5L80.2 70.4L80.7 69L83 70.1L82.8 71.5L84.1 72.9L86.1 71.1L84.3 65.8L83 65.8L82 63.7L83.8 61.9L85.1 64L87.9 62.6L90.5 63L90.7 60.1L91.8 60.8L93.6 60.1L94.1 61.6L96.7 61.6L96.7 59.1L97.7 58.7L97.4 56.2L92 50.5L90 45.9L93.8 47.7L97.7 47.7L98.5 46.2L100 45.9L100 44.5L97.2 43.4L96.4 40.9L95.4 41.6L93.3 39.8L92.5 36.6L88.4 36.2L86.6 33L87.9 31.5L87.4 27.2L88.4 27.6L91 21.4L92.3 20.7L93.6 14.2L88.2 13.1L85.9 15.3L84.6 15.3Z', w: 100, h: 97.1, b: [15.73, -50.9, 19.62, -47.12] },
BB: { d: 'M13 0L9.7 0L9.7 3.3L3.2 3.3L3.2 6.7L0 6.7L0 33.3L3.2 36.7L3.2 70L6.5 73.3L6.5 80L9.7 80L9.7 83.3L13 83.3L13 86.7L16.2 90L26 90L26 93.3L35.7 93.3L35.7 96.7L38.9 96.7L38.9 100L38.9 96.7L48.7 96.7L48.7 90L55.2 90L58.4 86.7L61.7 86.7L61.7 83.3L64.9 83.3L64.9 80L68.2 80L68.2 73.3L71.4 73.3L71.4 66.7L74.6 66.7L74.6 60L71.4 60L71.4 56.7L68.2 56.7L68.2 60L68.2 56.7L64.9 56.7L64.9 53.3L58.4 53.3L58.4 50L51.9 50L51.9 46.7L48.7 46.7L45.4 43.3L42.2 43.3L38.9 40L38.9 36.7L35.7 36.7L35.7 33.3L32.5 30L29.2 30L29.2 23.3L26 23.3L26 13.3L22.7 13.3L26 13.3L22.7 13.3L22.7 10L19.5 10L19.5 6.7L16.2 6.7L16.2 3.3L13 3.3L13 0Z', w: 74.6, h: 100, b: [-59.65, -13.46, -59.42, -13.15] },
BD: { d: 'M37.1 54.6L39.6 55L39.1 52.4L40.5 51.9L40.8 62.1L43.7 67.4L47.5 70.8L50.7 69L49.2 66.6L50.6 67.1L52.3 65.8L57.1 70.1L58.3 74.5L59.4 74.3L59.3 76.8L60.2 76.7L60.2 82.7L61.6 82.4L60.2 86.9L61.6 87L61.3 84.2L62.4 84.4L62.7 86.9L61.1 88L67.5 100L64.5 92.7L65 90L67.8 87.5L68.7 89.5L70.6 89.2L71.2 91.5L72.4 90.5L70.3 68.6L68.9 63.7L67.6 63.2L68.1 57.7L66.4 50.1L65.2 51.1L64.2 49.5L62.4 51.1L61.1 49.7L61.4 54L58.2 56.8L59.4 60.5L56 63.1L52.7 57.2L52.9 61L51.8 60.5L48.7 49.7L50 49.4L50 46.3L52.3 45.5L52.1 43.3L55.5 43.9L56.6 41.2L58.2 42.8L57.9 40.9L60.5 42.8L60.7 39.4L63.8 38.7L64.2 36.3L65.5 36.3L66.5 32.5L65.5 29.8L68.6 30.7L69.5 28.9L63 24.8L56.3 26L55.8 25L53.7 25.9L50.6 24.5L37.7 25.7L28.2 23.1L29 16.9L28.1 14L29.2 11.8L28.1 11.8L28.9 11.2L25.9 6.8L24.5 8.1L25.4 9.9L24.4 11.4L22 10L20.6 10.7L17.7 8.5L16.8 4L14.9 2.9L13.8 3.8L16.1 6.8L13.3 5.9L12.9 6.9L12.3 5.5L10.2 6.4L11.5 4.9L6 0L5 2.6L7.4 2.9L8.1 4.5L2.6 8.3L1.6 14.4L4 14.2L8.2 19.3L12.4 19.2L12.9 21.9L15.5 23.6L14.4 25.2L6.7 24.5L6 29.1L5.1 30.3L2 29.1L2.6 30.5L0 33.7L5 38.7L11.3 40.4L10.7 43.3L11.8 45.5L8.8 47.3L8.5 51.2L12.3 53.6L11 57.8L15.4 58.3L12.9 61.9L14.9 64.6L14 66.3L15.2 74L16.9 76.2L16 77L16.9 82L18.5 81.5L19.9 83L21.1 79.7L21.9 83.5L22.8 82.4L21.9 79.2L23.1 80.5L23.4 77.3L24.2 79.8L26.1 80L25.8 81.5L28.4 81.4L29.3 72.3L29.9 79.7L31.2 75.5L32.3 76.2L31.2 79L34.1 76.7L31.3 79.5L31.3 81.2L33 79.3L32.1 81.5L34.9 81.5L37.1 75.8L37.7 77.5L40.6 72.8L39.2 69.3L38 69.1L37.5 70.6L38.8 69L37.7 63.9L39.1 63.4L37.2 62.1L40.3 58L35.2 54.8L33 55L28.1 49.2L31 49.7L32 52.3L37.1 54.6Z', w: 72.4, h: 100, b: [88.01, -27.64, 92.68, -21.2] },
BE: { d: 'M21.2 5.8L17.1 7.5L16.8 6.6L0 17.4L2.3 23.1L1.3 24.7L2.6 28.8L4.7 28.4L6.7 32.5L9.3 33.7L10.3 31.3L15.8 29.7L16.8 32.5L18.6 33.3L18.1 35.8L19.6 40.7L21.4 41.9L23.8 40.3L25.1 40.3L24.8 41.9L27.6 41.5L28.9 43.1L28.9 46.8L30.2 49.6L31.3 47.6L35.7 48.4L38.5 47.2L41.1 50L41.1 51.2L42.1 51.2L41.9 50L43.4 50.8L41.6 53.2L41.1 56L42.6 56L43.7 58.5L41.3 60.5L41.3 62.5L42.6 63.3L46.3 62.9L49.9 64.1L55.6 61.7L55.6 58.1L58.9 54.8L60.7 56L60.5 58.1L59.4 57.7L58.9 58.9L59.4 60.1L58.1 62.5L60.7 65.3L59.7 70.1L63.6 69.7L66.1 71.3L66.9 73.3L67.7 72.9L67.7 74.1L70.3 73.7L72.1 75.7L71.6 77.3L72.4 76.5L73.9 76.9L75.5 78.9L75.7 81.7L77.8 80.5L79.6 81.3L80.1 79.7L85.3 79.7L87.1 74.9L86 74.9L86.6 73.3L86 72.5L85 72.9L84 69.7L82.9 69.7L82.9 66.9L83.7 66.5L82.7 65.3L85.3 62.5L85.5 59.3L88.4 56.4L88.6 54.8L90.2 54.4L90.7 55.6L92 54.8L93 56.4L93 54.4L94.3 54.4L93.8 52.4L96.9 50.8L97.2 48.8L99.2 49.2L100 48L98.2 46.3L99.2 43.5L98.2 43.1L98.4 41.9L95.1 41.9L94.3 40.7L93.8 39L96.4 36.6L94.1 36.6L92.5 32.5L90.4 32.5L89.7 30.9L86.8 31.3L86.6 30.5L85.5 31.3L83.7 30.1L81.1 30.9L81.7 29.7L80.1 26.8L83.2 23.1L82.2 22.7L83.5 21.1L84.2 16.9L85 17.4L84.5 16.1L85.8 15.3L85 14.1L83.7 14.9L82.9 13.2L80.6 13.7L78 12L78 9.9L76 8.7L74.4 10.4L72.6 9.5L72.4 10.4L69.8 10.4L69.8 8.3L66.9 7.9L65.4 5L66.1 3.7L65.6 1.7L64.6 1.7L64.6 0.8L61.5 5L60.7 3.7L57.6 4.1L57.6 3.3L59.4 3.7L59.4 1.2L57.6 0L54.5 3.7L51.7 3.7L51.7 1.2L49.9 1.2L47.5 2.5L48.8 6.2L43.9 5.4L44.2 6.6L42.1 9.1L35.4 12.8L34.6 12.8L34.9 12L32.3 12.4L32.3 10.4L27.1 8.3L27.1 9.1L25.3 9.1L25.6 10.8L23 11.2L21.7 9.9L21.2 5.8Z', w: 100, h: 81.7, b: [2.54, -60.29, 6.41, -57.13] },
BF: { d: 'M72.7 2.2L66.5 0L64.9 1L64.1 0L60.6 0L56.1 3.8L53 4.6L48.4 7.6L44.6 7.8L44.4 11.6L43.1 12.2L38.4 10.2L33.8 13.5L32.9 16.4L33.2 18.7L31.6 18.5L31.2 19.2L30.5 18.1L28.3 17.9L28.8 23.3L26.2 23.5L26.2 25L24.9 25L21.7 22.3L19.7 22.3L20.4 21.3L19.5 20.5L17.3 23.3L15.8 24L16.2 24.8L14.8 25.2L16.3 27.5L16.4 29.6L15.7 31L13 30.6L13 31.6L14.5 33.2L13.5 34.6L14 36L13 35.8L11.8 37.5L12.3 38.2L11.4 38.2L11.1 39.3L10.2 39.2L9.9 40.1L5.4 40.3L3.2 42.3L1.3 42.3L3.2 43.2L2.8 45.1L3.8 45.6L3.9 47.5L2.3 51.2L0.3 51.9L0.3 53.3L0.8 53.2L1.4 54.8L0.5 55.9L0.6 58.7L0 59.9L0.6 61.4L1.8 62.2L5.1 61.8L5.7 64.8L6.4 64.4L7 65.4L6.8 67.2L8.7 67.5L9.2 68L8.7 68.6L10.4 69.9L11.4 69.3L12 69.8L12.6 69L12.6 70.3L15.3 70.9L15.7 68.9L17.4 67.7L18.5 68.4L20.4 67L21.6 67.2L23.6 66.3L27.8 67L28.3 67.7L29.3 66.6L29.3 67.9L30.5 68.1L30.8 69.3L31.6 69.2L32.5 71.2L34.8 73.4L35.7 72.4L34.6 71.5L34.4 69.8L35.1 68.3L34.3 63.5L34.8 62.5L33.6 61.8L34.6 60.4L33.4 60L32.5 58L33.9 52.8L57 52.8L58.4 53.5L59.4 52.8L61.4 53.1L61.9 54.1L62.5 53.1L64.2 52.4L64.3 51.4L65.2 52.1L66.5 50.8L76.1 52.8L76 53.9L78 53L81.3 53L82 51.8L83.7 52.6L82.9 51L84.5 50.8L84.1 49.4L85.7 49.7L85.7 48.7L86.5 49.2L86.5 48.2L87.9 46.8L89.3 46.8L90.1 47.9L92.9 47.2L95.2 47.7L98.9 44.4L98.7 43.5L100 41.9L95.8 35.5L97 34.6L98.5 34.6L97.9 32.6L96.3 30.6L94.8 30.5L93.3 32.2L82.2 25.8L82.2 22.3L84.3 23L84.8 22.2L86 22.6L82.4 20.7L82.2 19.6L79.8 18.9L79.4 18.1L77.4 18L77.6 17.1L75.2 14.8L71.7 7.2L72.7 4.3L71.9 3L72.7 2.2Z', w: 100, h: 73.4, b: [-5.51, -15.26, 2.4, -9.45] },
BG: { d: 'M99.5 10.7L93.8 10.2L90.1 8.5L89 4.7L85.9 6L84.5 3.8L84 4.7L80.6 4.7L79 3.3L78.7 2L73.6 1.8L70.1 3.3L63.8 4L61.4 5.3L60.6 5.1L54.7 11.6L52.8 11.8L51.4 12.9L48 13.3L46.1 11.8L43.8 12L42.1 10.9L38.1 12L35.4 10.2L28.8 12L21.9 9.1L19.8 9.6L17.4 8.2L14.4 8.2L12.5 9.3L8.2 8.7L7.7 7.1L8.3 5.1L10.4 4.7L10.9 3.1L9 2.7L6.2 0.4L4.5 0.2L4.2 3.6L0.8 4.7L0 9.3L0.8 11.8L2.1 12.7L2.7 16.6L4.8 17.5L5.1 18.4L6.6 18.6L8.6 22.1L10.4 22.8L8.6 26.3L6.7 27.4L6.1 29.6L4 29.1L3.5 30L2.6 29.6L1.3 30.7L2.2 32.4L1.3 36.3L3.2 38.3L1.6 41.5L0 42L3.4 46.3L8.2 48.2L9.6 53.6L10.7 54.2L9.4 56.4L9.9 60.7L9.8 62.6L9.1 62.8L13.4 63.2L14.6 61.5L15.4 61.5L15.7 62.4L17.1 61.3L20.6 61.9L21 61.3L22.7 61.5L24.8 59.8L25.6 60.7L26.4 60L27 60.5L27.7 58.3L29.1 59.2L30.7 57.9L31.4 59.2L32.3 58.5L33.6 59L33.6 58.3L34.9 57.9L36 61.1L37.9 61.1L38.9 62.6L39.4 61.5L41 61.3L44.3 62.8L46.4 64.9L50.7 64.1L51 63.2L53.6 63.9L55.4 62.6L56.5 63.7L58.6 62.6L60.3 62.6L61.4 60.7L60.6 59.8L61.1 58.3L60.5 56.6L59.2 56L59.5 54.7L61.9 54L62.6 54.9L64 54.9L63.8 53L66.9 52.5L67.5 51.5L67.2 50.2L68.2 49.1L71.8 49.3L73.8 48.7L74.6 46.9L77.6 47.4L77.8 46.5L80.2 47.4L80.8 48.9L82.4 49.5L83.2 50.8L83.4 50L85.3 49.1L87.4 50L88 48.4L88.8 49.3L90.9 49.1L89.6 46.7L86.2 43.5L86.9 42L85.6 41.1L85.8 39.8L84.6 39.8L84.3 38.7L82.7 38.7L82.6 39.4L81.8 38.7L81.1 40L79.2 40L80.5 38.9L81.4 39.4L81.4 38.1L82.6 36.6L84.6 36.8L84.3 35.2L85.9 34.6L85.9 33.3L88.6 33.5L88.3 26.5L89.4 23.7L88.8 22.8L90.6 22.1L91.5 19.1L92.5 18.2L95.7 17.7L97.8 19.1L100 15.3L99.4 14L99.5 10.7Z', w: 100, h: 64.9, b: [22.36, -49.4, 28.61, -45.35] },
BH: { d: 'M37.9 1.3L36.7 0L34.3 2.6L37.9 5.3L35.5 5.3L35.5 4L36.7 5.3L35.5 6.6L30.8 5.3L31.9 10.6L29.6 9.2L30.8 10.6L29.6 13.2L28.4 13.2L29.6 10.6L28.4 11.9L29.6 11.9L24.8 10.6L26 10.6L23.7 13.2L23.7 11.9L18.9 11.9L18.9 13.2L14.2 14.5L16.6 17.2L15.4 21.1L9.5 21.1L9.5 19.8L8.3 21.1L8.3 19.8L0 18.5L0 19.8L9.5 21.1L7.1 22.4L8.3 27.7L11.8 27.7L11.8 21.1L15.4 21.1L14.2 21.1L15.4 21.1L15.4 26.4L16.6 25.1L15.4 26.4L18.9 30.3L17.7 30.3L20.1 36.9L15.4 47.4L16.6 51.4L16.6 50.1L17.7 52.7L20.1 52.7L27.2 61.9L27.2 67.2L29.6 71.1L31.9 64.5L33.1 65.9L31.9 64.5L34.3 63.2L33.1 59.3L35.5 55.3L34.3 55.3L34.3 50.1L35.5 50.1L34.3 46.1L36.7 42.2L35.5 29L36.7 30.3L37.9 29L36.7 25.1L42.6 22.4L41.4 21.1L36.7 23.7L35.5 21.1L36.7 19.8L34.3 18.5L33.1 22.4L35.5 23.7L36.7 27.7L35.5 27.7L35.5 25.1L29.6 21.1L28.4 17.2L31.9 17.2L31.9 18.5L31.9 17.2L33.1 18.5L33.1 17.2L35.5 17.2L36.7 14.5L34.3 14.5L35.5 13.2L33.1 14.5L34.3 13.2L33.1 10.6L35.5 11.9L35.5 9.2L36.7 11.9L37.9 10.6L36.7 14.5L40.2 14.5L39 15.8L41.4 19.8L41.4 17.2L43.8 18.5L42.6 14.5L45 14.5L47.3 19.8L45 14.5L46.1 13.2L45 14.5L40.2 13.2L41.4 10.6L40.2 7.9L42.6 7.9L42.6 6.6L40.2 7.9L41.4 6.6L40.2 4L40.2 6.6L37.9 5.3L39 2.6L37.9 1.3ZM56.8 80.3L56.8 79L53.2 80.3L49.7 84.3L50.9 84.3L48.5 89.5L49.7 88.2L50.9 89.5L49.7 97.4L53.2 100L54.4 100L50.9 97.4L53.2 94.8L52.1 92.1L54.4 90.8L52.1 89.5L52.1 86.9L56.8 85.6L55.6 82.9L54.4 84.3L56.8 80.3Z', w: 56.8, h: 100, b: [50.32, -27.31, 50.8, -26.46] },
BI: { d: 'M18.9 98.6L31 98.6L35.1 100L35.1 98.1L37 96.8L37.4 94.9L40.7 95.8L41.1 94.4L43.9 94L44.8 92.1L46.7 91.7L46.2 91.2L48.1 91.2L49.4 86.1L53.6 83.8L55 80.5L56.4 80.1L56.8 78.7L55.9 77.8L56.8 77.3L56.8 75.4L58.2 72.7L60.5 71.3L61 68.5L62.4 67.6L65.2 68.5L64.2 64.3L66.1 60.6L67.5 60.2L67 57.8L68.9 56L70.2 56.5L70.7 55.1L77.2 51.8L77.6 50.4L75.3 48.6L76.7 46.7L79 46.3L79.9 44.4L81.8 45.3L85 44L83.6 42.6L85.5 41.6L85.5 39.3L84.1 38.9L85 36.1L83.6 35.6L83.2 34.2L85.5 31L80.4 31.9L78.6 30.5L76.7 31.5L76.3 29.6L73.5 28.2L73 26.8L70.2 29.6L68.4 29.6L68.4 28.7L66.5 27.8L66.5 26.4L65.2 25.9L67 21.3L66.5 19.9L70.7 17.1L70.7 15.7L67.9 17.6L65.6 16.7L71.2 6L71.2 4.6L67.9 2.8L67.5 0.5L65.6 0.9L64.2 0L63.3 2.3L61 2.8L57.8 2.8L56.4 1.9L55 5.6L52.7 6L50.4 5.1L48.1 1.9L44.4 0.9L43.4 2.8L44.8 8.3L43.4 8.8L42.5 11.1L43.4 15.3L42.5 18.5L40.7 20.8L38.4 21.7L35.6 20.8L35.1 23.1L33.7 23.6L31 22.2L29.1 23.1L26.8 22.7L25 24.1L23.6 24.1L22.6 22.7L19.9 22.7L17.1 24.5L15.3 20.4L16.2 19.9L16.2 18L14.8 15.3L12.9 14.3L7.9 14.3L6.9 13L6.5 13.9L3.7 13L2.3 14.3L2.8 18.5L1.8 19L1.8 20.8L0 21.7L0 23.1L1.4 24.1L2.3 23.6L4.2 25.9L4.2 27.8L7.9 30.1L8.3 32.4L11.1 32.8L10.6 33.8L12 34.7L12 37.5L10.6 38.4L10.2 40.3L11.1 44.9L9.7 45.3L11.6 56.9L10.2 68L11.1 69.9L11.1 74.5L15.3 78.2L17.6 84.7L18.9 98.6Z', w: 85.5, h: 100, b: [29, 2.31, 30.85, 4.47] },
BJ: { d: 'M2.7 22.7L3 23L2.2 23L1.4 26.2L0.3 27.5L0 33.1L9.1 39.4L9.3 47.6L9.9 47.6L10.1 50.1L12.4 52.6L13.6 55.2L13.6 87.6L12.4 87.6L12.8 89.1L13.2 89.1L12.9 90.8L13.6 91.5L12.6 92.6L13.2 92.9L13.1 93.7L14.8 95L14.5 95.7L15.8 96.5L16.4 99L13.6 100L21.2 98.4L30.8 97.6L30.8 95.2L31.4 94.7L31.1 93.4L32.1 91.8L31.1 91.2L31.4 89.7L30.8 88.4L31.1 87.1L31.9 86.8L31.1 86L31.9 85.1L31.9 83.9L31.3 83.3L31.3 80.9L32.1 80.7L32.2 79.9L31 78L31.1 74.4L30 73.1L30.6 72.7L31.6 68.5L30.5 66.4L30.8 64.6L31.3 64.6L31.8 63.1L31.1 58.8L31.8 58.5L31.4 56.7L32.1 55.9L31.9 54.3L35 54.3L36.9 53.6L37.3 51.5L38 50.9L37.5 48.3L39.6 44.9L41.2 44.2L40.5 43.3L40.7 42.3L42.3 41.5L43.7 41.5L45 39.9L44.7 38.2L45.6 37.6L46 36.5L45.2 36.1L44.4 34.5L45 32.6L46.1 31.8L47.9 32.6L48.8 30L48.8 28L47.9 27.4L47.2 25.9L47.9 24.1L46.8 22.5L47.1 20.9L46.3 20.7L43.4 18.1L44.2 13.2L45 11.6L44.2 11.1L44.5 10.4L43.7 10.1L43.3 9L40.5 8.7L39.7 6.4L32.7 0L32.9 0.3L31.1 0.8L30.5 2.1L30 1.6L25.4 2.8L25.9 5.4L26.8 6.5L26.5 7.5L25.7 7.8L26 8.3L25.5 10.1L24.3 11.1L24.4 12.2L23.5 12.4L21.5 14L19.8 16.3L18.2 16.3L16.9 15.7L13.4 16.6L12.3 15.2L12.1 15.7L10.5 15.2L9.6 15.8L9.7 16.6L8.8 17L8.8 18.3L7.8 17.6L7.8 18.9L5.7 18.4L6.2 20.2L4.3 20.5L5.4 22L5.3 22.5L3.2 21.5L2.7 22.7Z', w: 48.8, h: 100, b: [0.78, -12.51, 3.84, -6.24] },
BN: { d: 'M73.4 12.5L73.4 10.2L72.7 11.8L71.9 11L75.8 6.3L74.2 3.9L76.6 3.9L78.1 0.8L81.3 0L75 0L59.4 6.3L57 9.4L44.5 17.3L40.6 21.2L42.2 20.4L41.4 22L39.8 22L28.1 30.6L14.1 35.3L0 36.8L4.7 36.8L9.4 40.8L10.2 40L14.1 42.3L18.7 51.7L18 54.1L19.5 54.9L18.7 61.9L26.6 62.7L28.9 60.4L31.3 69.8L32 69L32.8 71.3L34.4 70.5L35.2 72.1L36.7 72.1L36.7 76L38.3 76L39.1 78.4L41.4 78.4L43 80.7L41.4 82.3L46.1 81.5L49.2 79.9L51.6 76L56.2 72.1L57 69.8L55.5 69.8L60.9 60.4L57.8 62.7L57 61.9L62.5 54.9L62.5 48.6L59.4 49.4L57.8 47L57.8 35.3L56.2 32.9L56.2 29L54.7 28.2L54.7 25.1L57.8 24.3L60.2 20.4L64.1 18L70.3 18.8L71.1 13.3L73.4 12.5ZM84.4 11.8L79.7 11.8L79.7 13.3L82 14.9L81.3 14.1L81.3 15.7L79.7 15.7L80.5 16.5L78.1 15.7L76.6 17.3L76.6 19.6L75 18L75 20.4L76.6 20.4L73.4 23.5L75 25.1L74.2 28.2L75.8 29.8L75 33.7L78.1 38.4L77.3 40L78.1 39.2L78.9 40.8L78.1 43.1L79.7 43.9L78.9 48.6L80.5 50.2L78.9 51L78.9 52.5L84.4 52.5L86.7 54.1L90.6 53.3L90.6 55.7L95.3 55.7L94.5 57.2L96.1 58.8L98.4 58.8L100 57.2L100 54.9L93 47.8L93 37.6L94.5 37.6L94.5 32.9L92.2 31.4L93 28.2L90.6 27.4L92.2 26.7L92.2 25.1L89.8 23.5L90.6 19.6L85.9 15.7L86.7 14.1L83.6 14.1L84.4 11.8Z', w: 100, h: 82.3, b: [114.08, -5.06, 115.36, -4] },
BO: { d: 'M50.7 94.7L53.4 85.7L53.3 81.8L55.9 78.1L57.2 74.6L65.4 73.3L70 72L76.5 72L83.1 76.1L82.7 76.5L83 78.8L85 77.8L85.2 77.2L83.3 75.6L85.8 70.1L86.5 69.8L85.9 69L87.4 64L88.2 63.9L86.3 60.8L85.7 58.8L86 58.8L85.1 58L84.2 58.5L83.4 58L81.3 55.9L80.8 53L81 51.5L81.8 50.7L81.9 49L81.2 49.4L68.5 49L68 43L65.6 40.2L67.9 40.2L67.8 36.7L66.2 33.4L67 31.9L66.3 31.2L66.4 30.5L64.3 29.7L62.2 28.1L61.1 28.6L60.3 28.1L59.1 28.7L58.4 28.2L56.4 28.7L56.1 27.8L54.4 26.5L54.1 25.4L53.3 25.8L50.9 25L50.6 24.4L49.6 24.7L48.8 23.5L48 23.4L46.9 21.7L45.9 22.2L42.3 20.4L41.2 21.1L39.3 20.6L38.6 20.9L37.2 19.9L37.1 18.8L35.7 18.6L35.7 17.8L35.4 18.4L35.3 17.9L34.8 18.1L34.7 17.2L33.4 17.2L33 15.1L31.7 15L32.1 13.7L31.3 13.5L31.5 12.1L31 11.6L31.8 9.6L30.5 5.9L31.5 4.2L31.5 1.2L30.5 0L29.6 1.2L27.9 0.4L27.9 0.9L26.9 0.6L23.4 1.7L21.9 1.6L19 4.3L16.1 5.1L14 7.6L12.9 7.1L11.3 7.4L10 9.6L8 10.6L6.7 10.9L6.2 9.7L0.5 9.3L7.2 21.1L5.6 23.6L5.8 26.4L5.3 28.4L4.1 29.6L5.2 30.6L4.8 31.9L5.9 33.7L4.7 33.8L4.8 34.8L3 36.4L3 37.6L2 38.1L2 39.3L3.8 41.4L1.7 44.1L2.9 48.1L3.9 48.7L4.9 48.4L6 49.6L4.7 50.3L4.3 51.2L4.7 51.9L3.3 52.5L2 55.1L0.6 55.8L0 56.8L1.2 57.4L1.2 59.1L2.2 60.2L2.5 61.8L4.3 62.5L3.5 63.4L4.2 63.9L5 69.3L9 73L6.8 75.4L8.1 76.8L7.7 77.8L6.4 78.1L7.1 79.9L6.5 81L8.7 82.3L7.9 83.1L7.8 84.2L8.3 84.8L8.9 84.6L10.6 87.5L10.6 89.8L11.4 91.2L11.4 92.8L12.3 93.7L12.3 96L13.1 97.5L12.7 99.5L15 100L17.7 99.4L19 98L18.9 97.2L20.7 96.4L21 94.8L24.2 93.7L24.7 91.2L25.8 91.6L26 92.3L26.9 92.4L28.1 93.8L33.6 93.6L36.3 94.5L37 95.1L37 96.4L37.8 97.2L37.6 98L38.4 98.9L38.4 99.8L41.2 93L42.9 93L43.1 93.3L43.2 93L49.4 93L49.4 94.1L50.7 94.7Z', w: 88.2, h: 100, b: [-69.64, 9.72, -57.45, 23.54] },
BR: { d: 'M39.8 89.8L41.7 89.5L43.7 92.3L44.7 91.6L49.2 95.2L50.8 97L49.7 99.8L51.4 98.7L53.1 95.4L52.7 94.3L55.1 91.6L55.1 89.3L56.9 90.6L53.2 95.4L56.4 92.4L58.9 87.4L61.3 85.2L62 81.4L61.1 79.1L62.3 77.2L61.3 76.6L62.6 76.8L66 73.4L69.4 72.4L71.8 70.1L73.9 70.5L75.1 69.4L75.1 70.2L77.7 70.3L77.8 69L80.1 67.7L79.9 66.3L83 61.4L83.4 57.9L84.6 56.5L85.3 44.3L85.7 43.7L86.1 44.7L86.8 44.3L89.7 39.5L94 35.2L95.2 30.2L93.5 25.4L89.5 24.9L82.6 19.7L77 19.8L74.2 18.5L70.8 21L71.9 18.5L70.4 18.8L71.7 17.8L70.8 16.3L69.8 17.3L69.6 16L68 15.4L67.6 16L67.5 15L64.8 14.3L63 14.4L61.9 16.4L62.9 16.6L61.5 16.2L59.5 18.6L60 17L57.2 17.6L60.9 16.5L62.2 13.5L57.4 13.1L56.3 15.3L56.2 14.2L55.2 15.2L55.1 14.1L54.1 15.2L58.5 9.3L56.4 7.7L54.7 2.1L51.2 7.5L43.7 6.7L43.7 8.3L40.5 8L36.8 10L34.6 8.3L34 5.7L35.1 3.2L33.6 1.9L33.4 0L32.2 0.1L31.6 1.8L27.3 3L26.8 4L22.3 2.4L24 4.4L24.1 6.7L25.8 7.4L20.5 11.2L20.4 10.4L18.6 11L16.7 10L16 7.4L14.7 8.4L14 7.9L14.1 8.6L10.1 8.6L10 10.2L11.5 10.2L11.8 11.3L9.6 11.4L9.5 13.3L11.1 15.5L9.8 23.2L8.1 22.8L2.7 25.3L2.1 28.7L0 31.2L2.5 34.7L1.9 35.8L4 36L4.4 37.2L6.7 37.2L8.5 35.8L8.2 39.7L8.8 39.9L13.2 40L17.9 37L20.9 36.5L21.7 42.2L26.3 43.7L29.5 46L32.8 46.6L33.5 52.9L38 52.9L37.8 55.2L40.2 57.9L38.5 61.8L38.8 67.9L44 68.4L45.1 72.8L47.9 73.1L47.1 77.2L48.8 77.2L49.4 78.8L49 81.4L44.2 84.4L39.8 89.8Z', w: 95.2, h: 100, b: [-73.98, -5.28, -34.8, 35.89] },
BS: { d: 'M8.8 28.9L9.9 32.3L7.4 36.3L9.1 37L7.1 36.3L7.6 38.5L7.4 36.7L8.5 37.5L7.9 39.2L6.8 37.3L4.9 39.3L8.5 42L10 40.3L9.1 42.5L10.6 43L11.4 45.3L12.5 44.5L11.4 45.2L11.2 44.2L13.1 44L13.2 42.5L16.1 40.5L15.5 37.5L11.9 33L11.7 29.8L10 30.4L8.8 28.9ZM87.6 93L85.3 95.9L83.1 96.3L81.9 94.6L77.7 96.7L77.5 99.7L85.4 99.2L87.6 95.6L87.6 93ZM16.9 44.2L12.9 48L15.5 50L14.7 51.2L17 51.3L17.2 52.5L18.4 51.2L19 52.7L18.1 45.7L16.4 48.7L14.7 49L16.3 48.5L16.7 46.5L15.4 46.7L17.5 45.7L16.9 44.2ZM18.7 0.3L20.4 3.7L22.2 4.3L21.7 5.3L25.1 6.3L24.6 7.3L24.2 6L23.6 6.6L24.5 8.3L23.4 8.8L24.6 9.7L23.1 10.2L24 11.2L23.1 12.2L24.2 13.1L20.8 15.5L22.6 15.8L23.9 18.3L24.3 12.1L27.2 10L26.1 8.5L26.8 6.1L23 5.1L18.7 0.3ZM3.2 2L2.4 5.3L0.3 7.1L13.7 4.3L13.2 2.7L7.4 4.1L3.6 3.4L3.2 2ZM52.3 54.5L56.5 64.1L54 62.9L57.9 64.8L59.7 68.2L59.7 65.7L56.4 63.3L54.4 57L52.3 54.5ZM31.3 23L30.2 25.7L33 25.1L40 30.4L38.9 34.3L39.8 35.3L36.9 35.5L40.1 39L39.2 36.7L40.6 30.3L31.3 23ZM74.6 70.4L71.9 71.8L74.5 72.8L74.3 74.1L71.3 75.6L68.3 79.6L74.5 74.3L74.6 70.4ZM46.7 37.7L51.4 45L49.4 46.8L52.9 47L46.7 37.7ZM85.9 74.6L85.3 76.3L92 76.9L85.9 74.6ZM67.8 68.4L68.4 70.9L68.4 68.9L70.1 71.2L72.7 70.4L67.8 68.4ZM20.5 31.1L18.5 32.3L23.1 32L20.5 31.1ZM42.3 54.3L41.5 56.1L47.4 58.5L42.3 54.3ZM65.7 47.2L64 50.2L65.2 50.2L65.7 47.2Z', w: 92, h: 100, b: [-78.78, -27.97, -72.73, -21.39] },
BT: { d: 'M85.5 16.4L84.9 14.4L83.4 14.7L82.8 13.4L84.9 13.1L85.2 12.1L84 11.4L82.8 11.7L82.8 11.1L80.8 10.4L80.5 8.1L79.3 8.4L79.3 7.7L76.6 6.7L76.6 6L75.7 6.4L75.7 7.4L74 9.4L73.4 8.7L68.3 8.7L67.5 7.7L65.7 8.4L64.8 7L60.1 7L57.7 5.7L54.1 7.7L52.1 6L51.5 6.7L49.7 6L47.9 6.4L46.2 3L44.4 3.7L42.6 3L42 2L40.5 2L40.2 3L37.6 3.7L37.6 2.7L36.7 2L34 2.4L32.8 0.7L30.8 0L30.2 2.4L25.1 2.7L23.4 5.4L22.5 5.4L21 7L21.3 8.1L20.7 8.1L20.4 10.1L19.5 10.4L18.9 12.1L14.2 15.1L14.2 17.4L11.2 20.4L12.1 22.8L10.4 24.4L7.1 25.1L7.1 27.1L6.2 28.4L7.7 30.8L6.2 31.4L5.3 30.4L4.7 32.8L1.5 33.4L1.5 35.1L0.3 35.8L0 37.1L1.8 37.1L2.4 38.1L3.6 38.1L3.8 43.7L5.3 42.1L6.2 44.4L8 43.7L8.9 45.1L10.4 45.4L10.4 47.1L11.2 48L15.1 48L16.6 46.7L18.6 46.4L18.9 47.1L20.7 47.1L21.3 48.4L24 48L26.6 49L25.7 51L28.1 50.4L28.7 51L29.9 50.7L30.2 51.7L32.8 51.7L32.8 50.4L36.1 51L42 49.7L42.9 47.1L43.8 46.4L46.2 46.7L47.3 46.1L47.3 45.1L50.3 45.4L52.4 47.4L56.2 49L57.1 48.7L57.4 49.4L68 49L69.2 47.7L72.5 47.7L76.6 49L78.7 47.1L81.1 48.7L84.9 47.7L87.9 48L90.8 46.4L92.6 44.4L95 45.4L95.6 46.4L98.5 46.7L99.7 45.4L99.7 42.4L99.1 42.4L99.4 41.7L98.2 40.1L97.3 40.1L96.7 38.1L97 35.8L98.2 34.8L98.2 33.8L97.3 33.1L97.9 32.4L100 32.4L97.9 30.8L97.9 28.4L96.7 27.1L96.7 25.8L94.1 26.4L91.7 25.4L91.4 26.1L90.2 25.8L89.3 26.4L89.1 25.8L87.6 26.1L87.9 25.1L87.3 24.8L85.8 25.8L83.7 23.4L83.7 21.1L83.1 20.8L85.2 18.4L85.5 16.4Z', w: 100, h: 51.7, b: [88.75, -29.47, 92.13, -27.72] },
BW: { d: 'M53.2 0.1L52.2 0L51.5 0.6L50.1 0.1L48 1.1L46.3 3.1L45.6 3.1L44.8 1.8L44 1.9L43.5 2.8L42.4 2.6L36.7 7.7L35.9 7L35.7 5.2L34.4 4.6L33.5 2.3L31.4 2.3L14.7 5.7L10.1 5.7L10.1 45.4L0 45.4L0 75.9L1.1 77.2L2.6 77.6L3.9 79L4.3 80.3L4.8 80.2L4.5 80.7L5.6 81.8L5.2 82L5.6 82.8L6.4 83.2L6.3 83.7L6.8 83.5L6.4 84.4L7.1 85.2L6.6 85.7L7 85.6L6.9 86.3L7.7 87.1L7.5 87.8L8 87.5L8.5 89.2L8.3 91L8.7 91.3L6.1 95.5L6.3 98.6L7 99.3L6.9 100L8.8 98.8L11.3 99.5L13.6 99L17.2 99.4L18.1 98.5L18.1 97.3L20.7 96.9L22 95.4L22.2 94.1L23.9 93.4L24.5 92.2L25.8 92.3L26.1 91.3L27 90.6L26.9 89.9L27.6 89.8L27.8 88.7L27.4 88.5L28.1 87.9L27.6 87.4L28.5 86.3L28.3 85.6L28.8 85.4L28.4 85L30.4 81.9L31.3 82.2L33.2 81.5L34.6 82.1L34.9 81.5L35.4 82.4L38.6 84.2L38.4 84.6L39.7 85.7L42.1 85.3L44 87L45 86.7L47.1 87.7L49.6 87.6L50.9 86.6L54.1 87.1L55.7 86.2L57 84.7L59.5 77.6L59.2 75.7L64.8 74.6L66.7 71.9L69.3 70.4L70.5 64.1L70.8 63.4L71.1 63.9L71.6 63.6L71.4 63.1L72.3 62.6L72.1 62.1L72.9 62.2L72.7 61.9L73.9 61.4L74.2 60.8L74.7 61.1L75 60.7L75.2 61.1L75.3 60.5L76.2 60.7L76.9 58.8L78.4 58.9L78.6 57.8L80 57.2L80.3 55.9L81.4 55.3L81.3 54.5L84.4 51.6L86.2 51.9L90.2 50.4L91.3 47.8L94.9 47.6L93.8 46.2L92.3 46.2L91.4 45.5L91.2 44.2L91.8 43.2L89.6 42.8L86.9 41.4L85.9 41.8L84.7 41.1L81 40.7L80 37.8L77.8 35.5L78.2 29.3L75.9 28.8L73.7 29.1L73.8 26.9L72.9 24.6L66.9 22.4L66.4 21.4L64 20.2L64.3 19.5L62.4 18.9L60.3 14.2L60.7 13.3L60.4 12.4L58.9 11.2L58.5 8.9L56 6.7L54.6 3.6L53.5 2.8L53 1.5L53.2 0.1Z', w: 94.9, h: 100, b: [20, 18.07, 29.38, 27.96] },
BY: { d: 'M51.8 0L46.6 4.5L46 7.3L42.4 7.1L40.9 6L37.6 9.1L36.1 8.6L35.9 11.1L34.8 12.2L35.1 14.4L34 15.4L38.1 16.3L36.5 18.7L32.7 18.8L31.5 21.7L28.1 22.7L26.7 24.8L26.9 29.3L24.5 33.5L26.1 33.8L27.2 36.8L24.6 36.8L25.1 35.4L24.2 34L21.1 34.9L19.7 37.2L17.1 36.8L16.6 37.6L17.6 39L15.9 40.2L15.8 39.2L14.9 39.3L13.2 41.3L10.7 40L8.4 40.9L6.9 40.2L6.5 41.3L3.4 40.2L5.1 48.3L7.7 54.2L7.9 62L2.6 65.1L0 69.4L4.9 72.8L4.8 77.7L3.6 78.5L4.6 82.5L5.2 82.4L4.4 80.5L6.4 79.7L8.6 81.2L11.7 78.7L12.4 76.2L18.8 75.8L21.8 74.6L23.2 75.5L27.4 75L31.1 76.5L33.5 76.2L34.3 77.5L37 77.2L37.7 78.3L41.9 78L42.3 80.9L47.2 80.7L46.7 82.2L47.5 83L48.6 81.9L48.5 80.4L50.5 81.5L53 79.5L53.9 81.9L54.9 81L57.1 81.4L58 84L58.7 81.5L60.6 81.4L61.9 79.9L63.3 81.5L64 84.5L65.8 84.2L66.1 82.9L68 82L68.9 83.5L73 82.4L74.7 83.9L74.5 85L76.8 86.5L77.8 84.4L76.5 79.5L80.7 74.3L80.7 72.8L83 72.4L84.3 73.5L85.5 71.8L89.8 72.1L89.6 70.9L88.6 70.9L88.9 69.5L87.5 68.7L88 65.8L87.3 65.3L88.4 64.6L86.7 62.2L87.6 60.6L84 56.5L85.6 55.1L85.7 53.4L87.6 53.7L87.9 53L91.1 55.6L94.4 55.3L97.2 51.6L99.7 51.3L99.9 48.8L96.9 47.4L96.7 44.1L95.5 44.5L93.2 42.7L89.4 43.2L90.8 38.1L89.3 38.1L84.8 35.2L84 31.1L82.4 30.6L83.4 27.5L81.4 27.5L81.6 26.6L78.9 25.2L79.7 22.5L80.8 22.3L80.5 21.2L81.8 20.5L81.8 19L79.6 16.5L80.8 14.3L80.8 10.2L79 10.8L76.5 6.9L72.8 5.4L72.5 6.3L69.8 5.8L69.1 7.3L67 7.3L65.9 8.7L64.4 7.6L64.7 5L65.6 4.7L65.1 3.7L60.7 2.6L59 4.1L57.6 3.5L57.3 1.3L54.1 1.1L54.2 2.1L53.3 2.1L51.8 0Z', w: 100, h: 86.5, b: [23.18, -68.2, 32.78, -59.89] },
BZ: { d: 'M33.3 0.4L27.1 0.4L26 1.5L25.6 3.9L24.9 4.2L25.3 5.4L22.7 8.1L23.1 10L19.4 12.3L20.1 12.3L20.1 13.1L18.7 15L18.7 17.3L17.2 17.7L16.1 19.3L16.5 20L13.5 22L13.5 23.5L11.7 22.7L10.2 21.2L10.6 20.8L8.8 21.2L7.7 19.3L4 20L2.9 21.2L2.9 57.6L0 100L1.5 99.2L6.2 99.6L6.2 98.9L6.6 99.6L7.7 99.2L7.7 100L11.7 99.6L10.6 96.6L13.9 94.3L14.6 92.8L15.4 92.8L15.7 91.2L17.6 90.5L18.3 89.3L17.6 87.4L18.7 87.4L18.3 86.7L19.4 87.1L21.2 86.3L20.9 85.1L22 85.1L23.8 86.7L23.8 85.5L25.3 85.5L24.9 84.8L27.4 82.1L27.4 80.6L28.2 80.2L28.2 81L28.9 80.2L28.2 80.2L28.9 79.8L28.5 79L29.6 79L30 76.8L30.7 76.8L30.4 76L31.1 76L30.4 75.2L31.5 75.2L30.7 74.5L31.5 74.5L31.1 76.4L31.8 76.4L31.8 72.9L33.7 71L33.7 66.4L35.1 64.9L35.9 65.3L34.4 61.9L34.8 60.7L37 59.9L37 58.4L35.9 55L34.8 55L33.7 52.7L33.7 48.8L35.5 40L36.2 38.9L38.1 39.2L38.4 38.5L38.1 37.7L36.2 37.7L36.2 36.9L34.4 36.2L34.4 33.1L36.6 30.4L37.3 22L38.1 22.3L39.5 20.4L38.4 20.4L39.5 20.4L39.5 18.9L40.3 18.5L40.6 15.8L41.4 15L41.7 11.2L41 8.1L41.7 5.8L41.4 5L40.3 5L37 6.9L37 6.2L37.7 6.2L36.2 6.2L36.2 5.4L33.3 6.6L32.2 6.6L33.3 5L31.1 6.2L30.4 5.8L30.4 4.6L34 1.9L33.3 0.4Z', w: 41.7, h: 100, b: [-89.23, -18.83, -88.09, -16.1] },
CA: { d: 'M15.5 91L45.4 91.9L50.5 95.6L50.6 100L61.9 93L67 95.6L64.9 97.9L69 95.8L64 92.3L65.5 90.7L60.2 93.8L73.6 85.2L69.2 85.2L72.2 83.2L66.1 74.1L62.4 77.9L58.1 70.1L54.2 70.5L53.6 88.1L50.6 82.3L41.6 79.6L39.9 73.6L43.5 68.8L40.7 66.8L45.6 66.9L43.2 63.2L50.8 61.6L51.6 56.1L47.8 54.2L46.2 60.7L40 48.5L39.4 60.6L38.9 58L33.5 59.4L30 56.4L29.2 62.3L11.2 52.4L5.1 57.2L0 54.7L0 74L5 75.1L15.5 91ZM57.8 0L51 3L52.3 7.9L48.1 5.1L48.7 8.9L45.6 7.4L42.4 10.2L46.4 11.2L44.3 14L48.6 12.3L44.5 14.4L46.1 16L50.6 13L48 16.6L55.5 11.5L54.3 16.3L49.9 17.5L52.7 20.9L47 17.7L48.6 23.9L51.3 23.7L46.7 25.1L48.5 30.2L50.6 28L48.8 31.1L45.5 28.9L44.7 34.4L54 34.3L50.9 29.5L53.7 31.1L57.3 25.8L53.6 23.6L57.4 23.8L54.3 22.3L60 20.6L66 11.5L61.2 13.5L64.2 11.1L61.9 9.9L68.9 6.7L66.8 2.7L62.5 4.1L64.4 1.9L57.8 0ZM50.2 43.4L48.5 51.4L44.6 51.8L51.8 54.5L53.5 52L59.5 61.6L54.3 66.3L58.4 65.9L64.7 71.2L63 61.7L66.8 65.4L68.8 61.6L61.8 56.8L63.4 53.6L61.2 54.8L61.3 51.6L59.9 53.8L56.8 47.1L51.8 48.9L50.2 43.4ZM22.9 44.5L18.9 49.5L25.5 53.1L21 55.2L24 57.6L33.8 56.3L30.3 45.5L28.4 44.7L28.6 49.6L22.9 44.5ZM41 11.8L38.3 19.8L43.7 22.8L40.3 24L45 27.6L48.4 22.6L41 11.8ZM39.1 32L42.6 40.6L53.2 39.8L51.2 36.6L44.9 37.9L39.1 32ZM16.9 40.9L14 41.6L15.4 51L22.2 44.2L16.9 40.9ZM27.9 32.9L27.7 37.8L22.5 34.1L20.2 38.6L25.8 38.5L24.1 41.4L30.2 39.2L27.9 32.9ZM35.5 42.8L33 46.4L36.5 50.5L38.6 47.3L35.5 42.8ZM47.4 43.1L43.9 48.7L46.6 51.3L47.4 43.1Z', w: 73.7, h: 100, b: [-141, -161.85, -55.62, -45.93] },
CD: { d: 'M61.7 85.6L63.7 86.6L63.2 88.2L64.1 88.7L68.8 87.2L68.7 89.5L72.1 91.2L76.8 91.5L77.6 89.4L78.5 89.2L78.6 90.4L79.9 91.1L80.7 93L82.3 92.8L82.4 93.4L85 94.2L85 95.4L86.8 96.6L87.9 99.1L88.9 99.3L91 97.9L91.2 99.2L92.2 99.3L92.2 92.3L90.4 92.8L90.4 94L88.2 93.6L86.7 91.4L85 90.6L85.2 86.6L86.3 84.5L85.7 82.1L86.3 79.8L84.7 76.8L87.3 74.6L87.3 72.8L97.2 71.3L94.9 65.8L90.8 61.3L90.5 59.8L91.2 58.1L89.7 53.9L90.1 51.8L89.1 48L89.3 44.3L87.2 41.4L88.8 39.5L89.2 37L91 35.5L91.7 27.8L93 25.6L93.1 23.8L93.9 23.6L94.4 22.3L95.7 21.9L100 17.2L98.4 15.7L97 15.5L98.1 9.9L97.2 9L96.1 9.3L96 8.1L94.3 7.7L91.4 3.9L90.5 3.6L89.1 5.5L86.7 4.4L84.8 5.8L82.1 4.5L80.8 2.6L78.2 0.9L76.8 1.8L74.7 1.8L73.3 0.7L71.7 1.2L69.8 0L68.9 0.4L68.7 1.8L67.3 2.4L65.5 2.6L63.7 1.5L63.2 2.4L59.6 3.3L58.7 4.2L56.3 2.9L53.3 6.7L50 5.7L47.1 5.8L45.8 4.9L43.9 5.1L41.8 2.6L39.9 1.5L37.8 1.4L36.1 2.4L34.3 5.3L33.2 5.6L33.8 11.4L30.8 16.4L30.7 20.3L29.5 23.2L30.1 26.3L28.6 29.1L28.9 31.3L26.8 33.7L24.9 34.6L22.6 38.6L21 39.5L21 45.6L19.4 48.8L17.5 49.4L17.1 50.7L15.6 51L13 54L11.5 53.8L11.6 50.7L9.1 51.9L8 51.5L7.8 53.1L6.4 54L4.8 52.2L1.4 54.9L1.7 58.3L0 58.5L1 59.9L1.6 59.6L1.3 60L3.6 58.9L22.9 59L24.8 66.2L26.1 67.2L28 70.9L33.1 69.8L37.6 70.2L38.5 65L44.1 64.5L43.7 66.4L49.9 66.4L50.5 67.2L50 70L51 72.5L50.2 77.8L52.3 80.4L53 82.9L53 84.9L52.2 85.4L52.6 87.6L53.9 86.4L57.6 86.8L58.8 85.8L61 86.4L61.7 85.6Z', w: 100, h: 99.3, b: [12.2, -5.4, 31.31, 13.59] },
CF: { d: 'M99.9 46.5L98.4 44L98.6 42L97.8 40.7L95.3 39.8L95.1 39L92.3 38.2L92.9 37.3L91 35.3L92 33.9L89.6 32.6L89.1 31.1L84.7 29.1L82.7 27.4L82.5 26.7L83.5 26L82.9 24.5L81.1 23.7L80.1 22.1L74.9 21.1L74.7 20L75.5 19L75.5 18L69.8 17.9L70.3 15.7L69.8 16L69.2 15.5L69.6 14.3L70.9 13.6L70.9 8.8L68 4.1L65.8 2.4L64.9 0.6L62.7 0L59.5 0.9L58.4 0.6L56.7 1.4L55.9 4L56.4 4.7L55.7 6.1L54.4 6.2L53.1 8.3L52.4 8L51.4 9.5L50.4 9.9L49.8 11.7L47.9 12.6L47.9 13.3L46.9 13.1L46.2 14.6L43.5 14.5L40 15.6L35.7 15.6L34.2 16.4L34 16.9L36 18.3L33.9 21.2L32.5 21.9L32.1 23.1L27.7 23.3L26.9 23.9L24.8 23.6L23.7 24.5L22.1 24.8L21 26L18.9 26.4L18.7 27L17.3 26.3L16.6 24.3L15.4 25L15.1 26L12.7 26.7L12 27.4L10.6 27.8L10 27.2L8.2 27.1L6.1 29.3L6.1 30.4L4.9 33L4.1 33.2L2.5 37L0 38.2L0.7 39.8L1.5 39.7L1.6 42.7L0.8 44.6L1.8 45.1L2.4 49.6L5.1 51.9L5.2 53.3L5.8 54L5 54.2L6.4 56.6L10.6 61.3L12.3 62.1L12.8 63.2L12.7 66.2L13.6 68L16 63.3L16 60.6L16.9 57.9L19.4 57.7L20 58.1L21.8 57.3L22.5 57.6L23.5 56.6L26.2 57.2L26.3 57.9L26.9 57.4L27.2 57.9L28.5 57.7L28.8 58.3L29.5 57.5L30.7 57.6L31.2 57L32.3 58.4L31.9 55.9L32.5 54.3L31.6 51.9L33.2 51.3L35.8 47.2L36.7 47.1L38.3 45.6L41.4 45.8L42.1 46.8L44.2 47.5L45.8 48.7L47.2 51.2L50.1 50.9L52.1 52.1L52.8 51.7L54.6 52.4L56.2 52L58.9 52.6L59.9 53.4L61.6 53.3L62.3 52.7L62.9 51.5L62.7 50.6L63.5 50.7L64.1 48.8L64.6 49L65 48L65.6 47.9L65.9 48.6L67.2 48.6L67.9 49.5L69 49.8L70.3 48.5L75.5 47.2L75.6 46.6L76.5 46.4L76.1 46.3L76.3 45.8L78.2 46.4L79 47.5L80.8 46.7L81.7 47.2L82.3 46.4L83.7 46.3L83.9 44.2L85.2 43.6L88 45.3L88.6 44.7L89.3 45.2L89.8 44.6L90.3 44.7L92.5 46.3L94.7 45.8L95.6 46.3L96 45.5L97.5 45L99.8 46L99.9 46.5Z', w: 100, h: 68, b: [14.42, -11.09, 27.46, -2.22] },
CG: { d: 'M42.6 96.6L46.2 91.6L49.3 91.4L50.2 88.5L54.3 87.3L57.9 80.2L57.3 78.3L58.2 72.1L57.9 66.9L61.2 64.9L66.2 56.2L68.4 54.5L70.6 54.3L75 49L74.4 44.2L76.4 39.4L77.7 38.2L77.6 36.6L76.7 35.9L76.4 31.3L79 25L79.2 16.5L80.3 15.8L85.6 5.6L85.4 2.7L83.8 0.7L83 1.5L81.3 1.4L80.5 2.6L79.8 1.7L77.9 1.9L77.5 1.3L76.5 2.1L76.3 0.9L73.6 0.8L72.4 0L70.9 1.5L69.8 1L67.1 2.3L66.2 1.7L65.2 2.3L64 1.7L62.6 2.1L61.1 6.1L61.2 10.1L57.5 17.1L56.3 17.3L55.9 19.9L57.1 23L56.1 23.4L55.6 22.2L54 21.9L52.6 20.5L49.4 19.8L47.8 20.6L46.5 19.1L45.2 19.8L43.8 19.1L42.9 19.5L42.6 18.3L41.3 18.9L39.4 17.1L37.8 18.1L37.2 17.4L24.4 17.6L24.4 19.1L22.8 20.8L23.4 21.9L22.6 24.6L24.1 26.5L22.8 28.3L24.7 28.5L25.8 27.5L26.9 28L28.4 27L30.1 26.9L30.3 25.9L32.7 25.9L35.8 27.2L37.4 31.3L38.2 31.8L36.5 34.6L36.9 35.1L35.7 36.2L33.8 36L33.3 37.6L32 38.3L31.2 39.9L31.9 43L30.5 44.1L31.2 45.1L34.1 45.4L34.5 47.3L37.6 47.9L38.6 49.5L37.3 54L38.2 56.2L37.7 57.8L38.5 58.5L38.1 60.3L37 60.8L38 61.8L37.3 62.6L37.4 64.2L35.4 65.1L35.6 66.7L34.3 68L35.5 69.2L34.3 69.7L33.8 71.1L31.1 70.7L31.9 69.2L29.9 66.3L26.7 70.3L21.6 68.9L21.1 69.5L21.6 68.3L19.9 66.9L19.4 64.5L17.1 63.1L15.5 63.6L15.6 64.3L14.7 63.7L15.5 67.7L15 68.8L15.6 70L10.3 70L9.4 69.1L6.5 71.5L5.6 69.8L4.2 69.5L5.3 72.4L4.3 74.8L7.4 77.3L5.8 78.5L9 80L9.2 81.5L7.9 83.3L8.6 84.1L5.9 85.2L5.7 83.7L3.3 82.4L3.7 82.9L1.3 84.3L0 87.6L7.5 94.6L7.6 97.1L9.8 100L11.6 98.4L12 97.1L13.6 97L14.2 95.1L17.1 94.5L17.7 93.6L18.5 93.9L17.9 92.8L18.4 92.6L19.8 93.1L22.1 95.9L22.6 94.7L24.3 96.9L25.5 97.2L26 98.6L25.8 97.9L26.9 97.6L26.9 96.9L28.1 97.4L28.2 96.6L29.1 96.7L29.5 93.1L30.5 92.9L31.9 94L32.7 92.9L34.6 92.7L36.8 91.3L38 93.1L36.5 94.4L37.3 95.4L37.3 98.4L38.1 97.7L40.3 98.6L42.6 96.6Z', w: 85.6, h: 100, b: [11.15, -3.71, 18.65, 5.05] },
CH: { d: 'M80.6 24.5L77.5 24.5L78.6 23.5L77.9 19.9L78.8 17.7L80.8 14.4L81.9 14.4L81.7 11.8L80.1 11.5L79.2 8.9L73.1 4.9L71.1 5.3L67.5 3.9L64.5 5.3L63.8 4.3L64.5 3.6L62.7 3L62.9 2.3L62 3L62.7 4.3L61.1 3.9L61.4 1.6L60.3 1.6L59.6 0.3L58.7 1.6L58.7 0.3L57.6 0L57.8 1L55.6 1.3L55.2 3L53.9 3.6L55.4 5.6L57 4.6L58.9 4.9L58.1 6.9L57.2 5.9L55.4 6.6L55.4 7.9L52.3 7.9L51.7 6.6L49.2 6.2L47.2 8.2L43.9 8.9L42.8 7.2L41.1 7.2L40.2 8.5L38 9.2L37.1 8.2L38 8.2L38.2 6.9L37.3 6.9L34.2 8.5L33.8 10.8L32.5 10.2L33.1 11.1L31.6 12.4L28.7 12.8L26.7 12.1L27.4 10.5L22.7 10.2L23 11.8L21.9 12.1L20.3 15.1L23.6 14.4L24.3 15.7L21.6 17L22.1 18.6L16.3 23.8L16.6 25.1L10.4 28.7L11.3 30.3L10.4 32.6L11 33.5L3.3 40L4.4 40.9L2.2 45.1L4.6 47L3.1 49.6L3.8 50.5L0.2 51.8L0 54.4L4 54L7.3 51.5L7.7 50.5L6.6 50.5L5.7 48.6L8.2 45.7L12.4 44.1L15.9 44.1L19 44.8L17.9 47.3L19.9 50.2L18.3 53.7L20.8 54.7L20.1 56.9L21.4 56.3L25.2 62.9L26.3 62.3L27.4 62.9L29.4 61L31.8 61.7L33.8 59.8L35.1 59.8L35.1 58.8L37.5 59.1L39.1 61L39.5 60.4L42.4 61L43 58.5L45.3 58.2L45.7 55.3L48.3 54L48.8 52.8L46.8 50.2L47.2 49.6L49.4 48.9L52.1 46.4L51.7 45.1L55 43.8L54.7 50.5L56.7 51.5L58.5 54.7L60 55.3L61.4 54.7L63.8 55.9L62.5 58.8L64.9 60.1L66 62.6L65.1 63.9L67.8 64.2L69.1 61.7L66.9 59.4L67.8 58.8L67.3 57.2L68.9 56.3L68.7 54.7L72.6 51.2L73.7 48.3L72.6 44.8L73.5 42.5L75.3 42.2L76.4 43.5L77.3 42.2L77.3 46.4L79.9 49.3L82.8 49.3L83.2 47.3L84.3 48L88.3 46.4L89.2 47.3L89 49.3L90.9 51.5L93.2 50.2L91.6 48.3L92.9 45.4L90.1 44.1L90.1 41.2L91.4 39L94.5 38.4L94.5 40L95.8 40.9L99.3 41.6L99.8 40.6L100 38.7L98 38L97.6 36.4L98.9 34.5L98.5 33.2L100 28.4L98 26.4L96.9 26.7L95.8 29L94.5 28.7L94.3 30.6L91.4 31.6L89.8 29.7L86.5 28.7L86.5 25.8L80.6 24.5Z', w: 100, h: 64.2, b: [5.96, -54.58, 10.49, -51.67] },
CI: { d: 'M48 4.9L43.8 4.7L40.7 8.5L37.7 8.2L36.8 6.8L37.9 5.8L37.9 4L36.5 3.3L37.6 1.6L36.5 0L35.4 0.9L33.9 0.6L33.7 2.8L30.4 1.1L30.4 6L25.8 6L24.5 9.3L22.7 7.7L18.9 7.7L19.3 6.3L18.2 6.3L17.9 4.6L16.3 5.2L15.1 4.4L12 8.4L9.5 9L7 11.8L7.1 13.7L7.9 13.9L7 17.7L7.5 19.9L8.7 21.1L11.6 20.8L10.6 24.3L13.5 26.2L10.4 27.4L9.8 30.3L12.6 31L14.1 32.9L14.9 37.3L13.2 37.2L12.1 35.3L11.5 36.5L10.4 35.1L5.6 35.8L5.6 39.5L8.5 40.8L9.6 40L9.2 41.6L10.2 43L8.2 42.7L6.4 50.2L3.3 48.9L2 50L3.6 55L5 56L5 59L4.2 59.3L4 62.5L0 66.6L1.9 67.7L2.5 66.6L3.1 69L4 68.5L6.5 70.1L10.9 69.9L12.1 71.3L11.5 73L12.6 73L12.9 75.2L14 76L14.6 75.2L17.2 77.4L18.3 76.8L18 79L19.1 81L17.9 83.5L19.1 84.9L18.5 86L17.4 85.7L17.5 87.7L16.2 88.6L16.6 91.3L15.5 91.4L16.6 100L18.6 99.7L21.4 97.5L24.4 97.2L26.4 95.2L37.3 91.9L39.1 90.8L38.5 90L39.4 90.7L43.8 88.8L47.4 88.8L46.4 88.2L47.7 88.8L58.7 87.8L71.1 86L75.3 87.1L74.2 86.6L75.6 86.9L75.8 86.1L76.1 87.2L84.9 88.6L91.3 87.8L90.2 85.5L90.5 84.4L91.3 84.7L90.7 80.7L89.1 79.7L87.9 80.4L87.7 78.8L86.5 79L86.7 76.6L82.9 64.4L83.4 61.6L87.7 55L88.2 49.2L90.4 43.9L93.2 42.7L92.9 40.9L94.9 39.8L92.9 28.5L92.2 27.1L90.4 26.6L90.5 25.1L92.2 23.7L91.3 22.7L91.9 19.7L90.2 20.8L86.8 15.8L84 14.2L84 12.6L82.8 14L82.2 13.1L77.3 12.3L73 13.1L70.7 14.8L69.4 14L67.2 15.5L66.8 18L63.5 17.2L63.5 15.6L60.7 16.7L58.7 15.2L58.7 13.7L56.4 13.4L56.5 11.2L55.9 9.9L55 10.4L54.2 6.8L50.2 7.3L48 4.9Z', w: 94.9, h: 100, b: [-8.6, -10.8, -2.49, -4.36] },
CL: { d: 'M17 11.4L15.6 11.4L13.9 6.3L14.6 4.1L12.4 0L10.6 1.8L11.2 8.4L8.3 25.1L8.1 36.2L3.9 45.4L4.9 50.8L3.4 55L3.8 57L6.8 56L4.7 63.6L6.2 64.6L4.3 66.5L5.8 67.1L4.3 67.2L4 70.4L2.4 69.4L3.1 68.3L1.8 68.3L0 71L0.7 71.6L1.5 70.1L1.6 71.2L2.9 71L3.4 72.4L2.2 73.1L3.4 73.3L1.8 73.9L4.8 74.8L2 74.8L3.6 76L2.5 76.5L2.5 79.7L3.2 78.4L4 80L2.6 79.7L3.6 81.8L1.9 81.5L3.3 82L3.2 83.6L4.1 82.1L4.6 83.1L2.8 83.8L4 84.4L3.4 86L4.8 87.9L4.6 85.8L6.2 86.5L4.4 85.6L5.4 84.6L5 85.7L6.3 86.6L5.5 88.9L5.8 87.2L5.2 88.5L3.8 87.4L3.9 89.9L5.5 89.1L3.6 90.3L5.1 91L5.4 89.1L8.2 89.2L9.1 90.3L7.7 92.3L7.6 91.4L6.3 91.9L8.7 93.6L9.6 89.7L14.5 88.4L7.3 87.2L6.7 82.9L4.9 83.5L4.2 79.3L6.2 77.2L6.3 73.9L8 70.8L8.7 66.7L7.2 65.3L9.1 64.7L7.6 64.1L6.9 58L7.9 57.9L7.9 51.3L9.7 48.6L8.9 44.5L11.7 37.7L10.3 30.3L12.1 23.9L14.8 20.8L14.4 15.3L16.7 14.1L17 11.4ZM14.1 89.5L10.5 89.9L10.5 91.9L12.7 91.9L11 93.4L13.1 96L9.5 94.4L11.1 95.9L9.7 95.1L7.4 96.2L14.1 97.1L14.1 89.5ZM3.5 57L2.4 61.1L3.5 61.6L4.6 58.3L3.5 57ZM1.8 76.8L1.5 79L0.3 78.9L1.2 80.6L2.1 78.7L2.1 81.1L1.8 76.8ZM7.4 89.4L5.3 90.1L5.8 91.7L4.7 91.2L6.5 92.4L6.1 91.2L8.5 90L7.4 89.4ZM14.3 97.2L11.3 98L12.5 99.3L13.8 98.1L13.5 99.2L15.3 100L13.9 98.4L14.9 98.2L13.2 97.7L14.3 97.2Z', w: 17.4, h: 100, b: [-75.63, 17.78, -66.99, 67.4] },
CM: { d: 'M66.7 95.1L65.6 93.1L65.7 89.2L58.5 82.2L57 79.5L57.9 79.3L57.1 77.1L54.1 74.6L53.4 69.3L52.3 68.6L53.1 66.6L53.1 63.2L52.1 63.2L51.3 61.5L54.1 60.1L56 55.7L56.9 55.6L58.3 52.7L58.3 51.4L61.3 48.4L61.5 46.7L60.3 45.9L57.3 39L52 35.6L50.4 32.8L47.5 30.3L49.6 27.2L54.4 27.8L62.3 27.3L60.5 26.2L57.6 22.5L56.8 18.5L57.4 11.4L56.8 10.9L56.7 8.6L55.3 8.2L55 4L52.4 2.7L51.8 0L48.4 0L49.2 6L51.8 6.4L53.6 8.1L52.4 12L53.3 13.3L49.4 16.3L47.4 15.7L45.9 18.4L45.3 18.4L45.3 19.7L44 21.2L43.3 25.4L41.2 26.9L41.6 28.8L41 31L37.7 32.8L38.4 33.1L37.2 38.2L35.5 39.6L34.1 39.4L34.3 40.2L32.6 40.9L32.2 45.1L30.3 47.6L30.6 48.9L29.6 49.4L29.2 52.9L27 53.7L26.4 56.5L24.8 57.9L22.5 57.7L22.5 56.4L21 56.2L20 54.9L20.3 54.1L18.4 53.1L18 52.2L17.3 54.4L14.9 54.5L14.3 53.2L11.8 55.5L11 55.4L10.5 57.8L8.4 58.3L8.1 59.5L7.3 59.2L6.7 60.7L2.9 63.8L3.6 66.1L0 75.2L2 75.3L1.2 73.4L2.3 75.1L2.3 74.1L3.5 75.3L3.9 74.6L3.3 76.1L4.2 78.8L6.2 80.1L7.8 79.9L8.4 78.7L9.4 79.7L10.6 78.8L9.7 79.9L10.8 80.2L10.2 80.4L10.9 81.3L10.3 81.1L10.4 82.5L9.1 81.3L10.2 83.2L9.8 83.5L11.9 85.4L12.8 87.6L11.4 94L11.7 95L12.9 95.6L24.8 95.6L25 94.4L27.4 94.3L36.8 95.1L40.4 94.5L41.5 94.9L41.6 95.6L51.2 95.4L51.7 95.9L52.9 95.2L54.4 96.5L55.3 96.1L55.6 97.1L58.3 96.7L59.3 97.8L60.5 97.2L62.9 97.7L65.2 99L65.5 100L66.3 99.7L65.5 97.3L65.7 95.3L66.7 95.1Z', w: 66.7, h: 100, b: [8.5, -13.2, 16.19, -1.66] },
CN: { d: 'M1.7 38.8L3.1 38.9L4.3 41.2L6.3 41.9L9.5 40.9L11.2 41.8L10.7 43.4L8.8 44.9L9.9 47.5L8 47.7L8.6 50.1L12.4 52.5L14 51.9L20.4 56.4L24.9 56.1L25.2 57.5L26.6 55.8L30.9 56.7L34.5 53.8L36.8 53.7L37.5 55.5L39.4 55.3L41.1 57.1L41.1 60.2L39.7 61.3L39.2 63.7L41.4 63.2L41.4 65L42.5 65.4L41.9 66.8L43.2 67L43.4 67.9L45.1 67.5L46.1 68.6L46 66.2L49.8 66.2L51.9 64.6L54.4 65.6L54.1 67.1L55.3 67.8L58.9 67.5L60 70.2L60.2 68.1L64.3 67.3L64.3 66.2L65.4 66.7L65.1 65.1L66.6 66.6L71.9 64L72.8 63L72.3 62.6L73.6 62.6L74.8 60.6L75.3 61.1L75.1 58.5L76.1 58.8L76.8 56.1L78.6 55.6L78.2 54.1L79.1 53.3L78.2 53.5L79.4 52.7L76.1 52.2L79 50.7L77.1 48.7L76 48.8L79 49.2L77.3 47.5L76.4 44.2L74.5 43.2L76.1 40.4L80 39L80.3 38L77.1 37.1L74.6 38.5L74 36.3L72.1 36L72.2 34.4L74.2 34.3L79 30.3L79.6 31.5L77.8 35.3L82.8 32.9L86.8 28.8L89.1 29.6L89 28.2L91.7 27.3L92 26L93.2 27.3L92.8 26.6L94.3 25.2L93.8 21.8L95.3 20.7L97.3 21.2L100 13.5L93.8 15.1L93.3 12.2L88.2 9.9L85.1 1.3L81.6 0L77.3 0.7L75.9 2.2L77.2 3.8L74.7 9.1L72.4 10.6L70.5 9.8L68.6 14L69.2 15.1L73.5 14.4L75.7 17.6L71.7 17.8L65.5 22.1L62.8 21.3L61.9 23L62.7 24.5L60.2 26.6L55.4 27.2L51.4 29.2L44.8 26.8L37.4 26.6L35.7 23.1L32.7 21.6L28.4 21L28.6 17.5L27.5 15.2L23.6 13L23.4 11.5L21.9 11.6L21.4 13L19.7 13.9L19.7 16.7L15.5 16.3L14.4 19.3L14.8 21L13.5 20.6L10.4 21.7L11.9 25.6L10.9 28.1L5.5 30.4L4.6 31.8L3.4 31.1L0.7 32.5L0.4 35.2L2 35.9L2.7 37.9L1.7 38.8Z', w: 100, h: 70.2, b: [73.5, -63.67, 134.78, -20.66] },
CO: { d: 'M45.9 3.7L47.1 2.6L46.4 1L43.7 0L43.6 0.6L42 1.1L42.7 1.4L42.3 1.8L42 1.2L40.7 1.5L40.2 3.5L37.2 4.7L34.1 7.2L29 6.8L27.6 10.5L26.8 9.1L28.2 8.9L24.8 8.2L25.3 8.9L24.4 8.6L20.8 11.5L21 13.1L19.8 14L20.8 13.5L20.9 14.8L19.7 16.7L20.5 17.2L20.2 18.3L17.4 18.9L16 21.5L12.4 23.6L13.5 24.6L13.5 27.5L12.5 27.5L13 26.2L12.4 26.5L9.6 23.1L9.3 24.2L11.1 27.4L10.1 27.6L8.7 29.9L7.7 28.8L7.9 30.2L7.1 30.2L6.7 31.7L7.9 32.8L7.8 34L9.9 35.7L9.2 37.9L10.5 40.2L8.8 42L9.7 42.3L10.5 46.6L10.1 46.4L10.2 48.4L8.9 49.8L10.1 50.5L9.4 50.7L9.9 51.4L10.9 50.3L10.4 51.9L11.3 51.2L12 51.8L8.9 55.2L9.2 55.8L8.1 57.1L8.6 57.3L7.5 58.4L7.9 58.6L5.7 59.2L6 60.1L5.2 59.3L5.4 60.3L4.6 59.2L4 61L3.8 59.9L2.6 60.4L2 61.7L2.9 64.4L1 64.1L0 65.2L2.3 67.3L4.2 67.6L4.9 69.2L8.1 69.9L8 70.6L9.2 70.9L8.9 72.3L14.5 73.5L15.5 73.4L15.5 72.5L16.4 72.2L19.1 74.5L25.2 76L27.6 78L28.3 80.7L29.8 80.7L30.7 82.1L32.5 82.6L33.1 84.1L32.7 85L35 85.6L35.4 87.4L34.8 88.1L36.3 89.4L38.2 88.8L41 89.5L43.5 87.5L45.4 89.1L47.9 88L50 89.6L51.9 89.7L51.6 90L53.2 90.6L53.4 91.2L49.4 97.4L52 97.6L54.1 100L57.3 81.4L55.9 79.3L56 77.8L53.4 75.9L53.5 71.4L56.1 71.2L56.9 70.4L57.6 71.2L59 71L58.2 68.5L54.6 68.4L54.6 64.6L64.7 64.5L63.9 63.9L64.4 62.6L66.2 64.1L67 62.6L67.6 62.9L69.3 61.5L69.6 63.2L70.7 64.1L71 67.8L72.5 67.5L72.5 66.7L70.3 60.6L67.9 58L66.5 58.2L69.7 54.6L68.6 52.3L67.8 52.3L66.5 47.8L66.5 43.3L67.9 42.1L68 40L69.2 39L68.8 37.7L66.7 37L64 38L59 37.7L57.9 38.6L53 33.1L51.8 33.4L49.7 32.4L47.6 33.2L43.2 32.5L41.6 32.9L40.7 30.8L38.9 30.1L38.9 27.4L39.7 27L39.7 26L37.9 23.3L37.3 20.4L36.2 20.5L35.8 19.2L33.6 19.9L36.2 15.9L36.4 12.2L38.8 8.3L40.3 8L42 4.9L45.9 3.7Z', w: 72.5, h: 100, b: [-79.02, -12.56, -66.85, 4.23] },
CR: { d: 'M66.2 8.4L67.4 11.7L66.8 12.9L59.1 15.3L56.8 12.9L55.6 13.8L51.2 13.2L51.5 12.6L50.3 12.3L50.9 10.5L47.6 9L46.8 6.6L45 8.1L40.6 5.1L37.4 4.2L30.6 8.1L10 0L7.6 4.2L8.2 5.4L6.2 5.1L5.6 6L7.6 7.2L6.8 9L4.4 8.4L4.4 9.3L4.1 8.1L1.8 8.1L2.6 8.7L0 9.6L2.4 10.2L2.6 11.1L4.4 10.8L4.1 11.7L5.3 12.6L7.9 12.6L8.5 16.8L7.4 18.3L9.4 17.7L7.4 20.1L3.8 21L5.3 23.1L2.1 25.7L3.2 27.2L3.5 26.6L2.6 28.7L8.2 39.5L18.5 41.9L24.7 49.9L26.2 46.4L28.2 45.5L27.4 44.6L29.4 44.6L32.1 41.6L30 41.6L30.3 38.6L22.4 35.9L22.4 35L23.5 35L19.7 32.6L21.2 32.9L20.6 29L19.7 28.4L20.9 28.7L21.5 31.1L25.6 31.4L25.6 32.6L26.8 32.3L29.4 34.1L28.8 34.7L33.8 36.8L32.4 37.4L35.6 37.4L36.5 38.6L35.9 40.1L38.8 42.8L37.4 47L39.4 49.3L41.5 50.8L45 50.8L51.2 52.9L50.3 51.7L52.4 53.5L52.1 54.7L56.5 55.6L59.4 57.4L68.5 65.1L68.2 69L70.3 69.3L68.5 69.3L70 70.8L67.6 72.6L69.7 72.3L67.6 73.2L67.4 75.6L65.6 75.9L65 78.5L70 83L73.5 83L78.2 85.1L78.2 80L75.3 78.8L72.4 75L76.2 74.1L77.4 74.7L77.9 76.7L80.9 77.6L81.8 77L82.9 78.5L81.8 78.8L81.8 77.3L81.5 78.5L83.2 79.7L82.1 79.7L84.1 82.7L82.4 85.4L87.1 88.3L89.7 93.1L89.7 94.9L90.3 93.7L88.5 88L87.1 88L85.3 86L91.5 81.8L91.8 77L89.1 73.2L90.9 72L90.9 70.5L93.8 69.9L95.3 68.1L90 63.6L88.5 63.9L88.5 52.3L91.5 51.4L90 49.6L90.9 47.8L94.1 48.7L97.4 51.7L98.5 51.1L98.5 49.6L100 49.9L97.6 47.3L93.2 46.7L92.4 44L91.2 44.3L87.6 39.5L86.8 39.5L85.9 36.5L84.1 36.8L78.2 29.6L78.5 30.8L71.2 19.5L71.8 20.4L70 14.7L69.4 15.6L69.1 15L68.5 10.2L69.1 13.5L69.4 12.6L68.2 9.6L66.2 8.4Z', w: 100, h: 94.9, b: [-85.95, -11.29, -82.55, -8.07] },
CU: { d: 'M35.2 0L31.9 1.7L31.2 1.7L31.2 0.6L24.7 0.3L20.2 2.3L20.2 1.8L18.4 1.9L18.8 2.3L18.2 2.5L17.4 2L16.5 2.9L15.9 2.1L14.3 3.5L13.7 3L12 4.3L11.5 4L9 5.4L8.5 4.9L8.6 5.5L6.7 6.4L5.1 8.8L4.7 10.2L5.8 11.9L6.3 11.4L6.2 12.1L4.2 11.6L1.4 13.1L0.2 12.9L0 13.6L1.4 14L4.3 12.8L4.1 14.6L6.4 13.1L8.5 13L9.5 10.4L11.9 10.5L12.8 9.7L13.5 10.4L14.6 10.1L15.3 8.8L16.7 8.6L17.2 7.2L19.8 6.1L20.4 5L28.6 5.3L30.5 6.4L30.3 7.4L25.8 8.1L29 10.4L32 10.1L32.9 10.4L33.1 11.5L34.3 10.7L34.6 11.6L34.8 9.3L36 11.5L41.5 11.4L43.3 13.3L45.5 14.1L45.8 14.9L46.9 14.5L47.2 15.4L47.9 14.9L52.9 16.7L57.3 15.7L57.9 16.3L57 16.8L58 16.6L59.6 19.3L59.7 21.8L62.8 24.4L63.8 25L71.3 25.3L71.2 26.6L72.7 27L72.6 28.2L68.1 31L66.6 33.5L80.6 31.9L83.4 32.3L83.9 31.6L83.9 32.2L86.4 33L89.3 33.1L90.6 32.5L90.3 31.7L91.1 31.3L90.6 33L93.6 31.5L98.6 31.3L99.9 30.1L99.1 28.8L97.6 29.2L96.5 28.5L94.5 25.8L89.9 24.8L89 24.8L89 25.5L88 24.5L86.8 25.2L86.2 24.2L84.9 25L84.8 23.6L86.9 24.1L85.7 23.3L86.6 21.8L85.4 20.8L81.5 21.2L81.3 20.4L79.2 19.3L77.7 19.4L78.3 19.9L77.8 20.3L77.1 20.1L77.6 19.2L76.5 19.6L76 19L76.9 18.8L75.3 18.2L74.6 19.2L75.2 18.2L73.8 17.8L72.8 16.1L71.4 17.7L70.9 16.9L71.6 16.6L70.1 16.1L72.1 16.7L72.2 15.5L69.4 14L70.4 15.8L69.5 15.5L69.1 14.3L66.6 14.2L65.8 12.4L65.2 13.5L63.7 11.3L61.5 10.7L60.6 9.6L57.6 8.2L57.7 9.2L57 8.2L51.6 7.9L49 4.5L47 4.1L47.7 3L46.8 3.7L45.3 2.5L43.3 3.1L39.5 1.1L37.2 1.6L36 1.1L35.1 1.9L33.9 0.8L35.2 0ZM18.2 12.6L17.2 14.3L18.4 16.2L17.1 16.6L16.3 15.8L16.7 16.9L19.5 17.7L22.2 16.6L21.9 14.6L20.7 13.9L20.8 13.2L18.2 12.6Z', w: 100, h: 33.6, b: [-84.95, -23.87, -74.13, -20.24] },
CV: { d: 'M60.2 72.4L59.1 72.8L60.2 76.3L59.1 78.2L59.5 81.3L58.7 81.3L58.7 82.8L61.3 84.7L60.6 85.5L64.3 88.6L69.9 88.6L71 86.7L70.6 85.9L71.7 85.5L71.4 83.6L70.3 83.2L69.1 80.1L68 80.1L67.7 79L66.9 79.4L65.4 76.7L62.1 74.7L62.5 72.8L61.3 72L61 72.8L60.2 72.4ZM10 0L5.9 1.6L4.5 3.1L0.7 4.3L0 6.2L1.5 7.4L1.9 11.3L7.1 10.9L7.4 9.7L8.6 9.3L10.4 7L11.9 7L14.5 5.1L14.9 3.5L13 2.7L12.6 0.8L10 0ZM91.1 37.7L90.3 39.6L91.1 40L91.1 41.9L89.2 43.1L88.8 45L89.6 46.2L91.4 46.6L92.2 47.7L95.2 47.7L95.9 46.6L99.6 44.6L100 40.7L98.1 38.4L95.2 37.7L95.2 38.4L92.6 38.8L91.1 37.7ZM36.8 83.2L34.6 84L34.2 84.7L33.8 84.4L32 87.1L32 89.7L34.9 92.1L38.3 92.1L40.1 90.1L40.1 86.3L39.4 84.4L38.3 83.2L36.8 83.2ZM37.9 20.2L34.9 21L34.6 22.9L37.2 24.1L37.2 25.3L38.7 27.2L38.3 28L39.4 28L39 27.2L40.1 24.5L41.6 23.7L44.6 23.7L46.5 25.3L50.2 24.9L48.7 22.9L45 22.9L42.8 21.4L40.1 22.1L37.9 20.2ZM81 72.4L81 73.2L80.3 72.8L80.3 74L79.2 74.3L79.9 76.3L79.2 76.7L78.8 79L80.3 80.5L82.2 80.5L84.8 78.2L84.4 75.5L83.6 74.7L84 73.2L81.8 73.2L81.8 72.4L81 72.4ZM16.4 10.9L13.4 11.3L13.8 12.4L13 12.8L12.3 12.1L11.2 13.2L10.8 12.8L10 14.4L11.2 14.4L11.5 15.5L11.9 15.2L13.4 16.3L18.6 15.2L18.6 13.2L16.7 12.4L17.1 11.3L16.4 10.9ZM91.1 14L90.7 13.2L88.1 14.4L88.1 19.4L90 19.8L89.6 21.8L90.3 22.1L90.3 23.7L91.8 23.7L91.4 21.8L92.6 20.6L91.4 18.3L91.4 17.1L92.2 17.1L91.4 15.5L91.8 14L91.1 14ZM24.9 89L22.7 90.1L22.7 91.7L23.4 91.3L23.8 92.4L25.7 92.1L26 90.9L24.9 89ZM22.7 16.3L22.7 15.5L21.6 15.2L21.2 16.7L23 17.9L25.3 17.5L23.8 17.5L23.4 16.3L22.7 16.3Z', w: 100, h: 92.4, b: [-25.36, -17.46, -22.67, -14.98] },
CY: { d: 'M31.9 55.9L32.8 54.3L36.6 52.2L41.8 52.2L42.2 52.8L43.5 51.7L47.4 51.2L50.4 49.1L53 48.6L55.2 46.5L57.3 46.5L57.3 47L57.8 45.4L59.1 44.4L59.5 39.6L62.1 37L61.6 35.4L60.3 34.4L62.1 32.8L62.1 34.9L63.4 33.8L64.7 34.9L66.4 33.3L68.1 33.8L69 32.8L69 30.7L69.8 30.7L71.1 32.3L70.3 33.3L69.4 32.8L68.1 33.8L66.4 33.3L65.5 34.4L66.8 34.9L67.2 36.5L68.5 37.5L67.2 38.6L70.3 38.6L70.3 39.1L71.1 38L73.7 37.5L73.7 38L73.7 37.5L73.7 38L77.2 38.6L78 39.6L78.4 38.6L78.4 39.1L77.6 38L77.6 36.5L74.6 32.8L74.1 33.3L72.4 30.7L72.8 30.1L70.7 28.6L70.3 23.8L71.1 23.3L72 20.6L73.3 19.6L75 19.6L75 20.1L77.6 19.6L78.4 18L78 17.5L79.7 16.4L81 14.3L87.9 11.1L89.7 9L91.4 8.5L91.8 7.4L92.2 8L93.1 5.3L94.8 4.2L99.1 3.2L100 0L97.4 1.6L96.6 1.1L96.1 2.1L94.8 2.1L94.8 2.7L93.5 2.1L93.1 3.2L90.1 3.7L87.9 6.4L86.2 6.9L86.2 6.4L85.3 7.4L82.3 7.4L81.5 9L80.6 9L80.2 10.6L78 10.6L76.7 12.2L74.1 12.7L72.8 13.8L72.4 13.2L72.4 13.8L68.1 15.4L64.7 14.8L60.3 18L59.9 17.5L59.9 18L57.3 18L57.3 18.5L56.9 18L56.9 18.5L54.3 18.5L52.2 19.6L51.7 19.1L48.7 19.6L48.7 19.1L40.9 18.5L40.9 18L38.4 18.5L37.1 17.5L34.9 18L34.9 18.5L34.9 18L31 17.5L30.2 16.4L28 15.9L28 18.5L28.9 19.1L28.4 24.9L27.2 28L24.6 29.6L19 27.5L19 27L18.5 27.5L16.4 27L16.4 26.5L13.4 27.5L13.4 28L12.1 27.5L9.5 32.8L6.5 34.9L3.4 34.4L2.2 32.8L1.3 32.8L1.3 31.7L0.4 31.2L0 34.9L1.7 37L1.7 42.3L3 43.3L3.4 44.9L3.9 44.4L5.2 45.4L5.2 48L6 49.1L5.6 50.1L6.5 50.1L6.9 51.2L7.3 50.7L9.5 52.8L9.9 52.2L9.9 52.8L10.3 52.2L12.5 52.8L14.2 54.3L18.1 55.4L18.1 55.9L21.1 55.4L21.1 53.8L22.8 53.8L24.1 52.2L25.4 52.2L25.4 53.3L27.2 54.8L27.6 54.3L28.4 54.8L28.9 53.8L31 53.8L31 55.4L29.7 54.8L29.3 55.9L29.7 56.4L31.9 55.9Z', w: 100, h: 56.4, b: [32.27, -38.26, 34.59, -36.95] },
CZ: { d: 'M71.6 55.9L75.3 50.1L77.8 51.6L79.2 50.7L80.4 51.6L83 50.5L84 49L85.4 49.2L86.1 46.9L88.8 46L90 40.8L92.8 40.1L95.6 35.8L98.5 36.3L100 35.2L99.3 32L96.6 31L95.9 26.5L94.5 26.9L92.3 25.8L91.7 26.5L87.9 23L87.3 23.7L88 24.4L86.3 25.1L83.9 24.2L83.2 22.1L81.2 20.9L83.8 19.8L82.9 17L82 18.4L77.7 18.6L77.7 17L76.5 17.3L75.5 15.6L71 14.2L70.5 15.2L73 19.3L70.2 19.8L67.7 22.3L63.1 15.9L60.9 15.2L60.7 14.2L63.2 13.1L64.3 11.2L62.6 9.1L61.2 9.1L61 10.1L59.2 9.4L57.9 10.8L57.6 8.7L55.7 9.1L55.1 7L53.2 7.5L49.6 5.9L48.4 6.6L48.4 5.2L47 3.8L47.1 1.9L45.6 1.9L45.5 0.9L45.1 1.6L42.7 1.2L43 4.5L41.1 4.2L40 5.6L38.8 5.6L37.4 4.7L37.8 3.1L36.5 3.3L36.6 1.2L32.8 0L32.1 1.9L33.1 1.9L34 3.8L31.6 4L29.4 5.9L27.9 5.6L26.1 7.7L21.3 8.2L20.5 10.5L18.9 9.6L18.2 11.2L17 11L16.4 13.1L13.9 12.9L12.7 15.4L10.8 14L9.2 15.4L6.2 15.4L3.5 19.1L3.7 20.7L1.5 17.3L0.1 17.3L0.7 18.2L0 18.9L1.6 20L1.8 22.3L6.8 26.5L4.6 30.6L6.5 32L8.4 37.7L10.3 39.7L13.1 39.9L16.1 44.4L19.4 46.3L19.8 48.1L20.7 48.7L22.2 48.1L23.3 50.1L24.4 49.9L25.4 52.5L29.1 54.6L28.4 55.5L29.2 56.6L33.2 57.5L35.2 55.2L38.7 56.8L40.2 52.3L42.7 52.5L43 46.9L45.2 47.4L45.3 48.7L47.3 47.6L53 50.5L55.5 50.1L59.7 53.2L63.5 53.4L64.5 51.6L67.4 52.1L67.8 53.4L71.2 53.7L71.6 55.9Z', w: 100, h: 57.5, b: [12.09, -59.58, 18.86, -55.69] },
DE: { d: 'M1.2 57.4L3.2 59L2.8 60.7L4.1 61.2L4.3 62.8L2.4 64L1.9 66.1L3.6 68.8L5.3 69.2L4 73.5L5.5 73.9L6.9 77.1L8.6 76.5L9.5 77.8L12.7 77L14.1 78.6L18.9 79.6L15.5 84.2L13.7 89.9L13.2 95L14.5 96.9L21.8 96.1L22.1 95.4L20.3 94.9L21.7 93.6L22.9 95L23.6 94.4L24.2 95.5L27.3 95.4L29.6 97.3L31.4 96.1L32.9 96.7L33.9 99.1L35.1 98.7L34.5 100L36.7 98.7L36.8 96.6L40.3 96.8L41.2 98.6L43.5 98.5L46.2 96.2L50.7 96.1L51.1 94.4L53.1 95.7L55.4 95.3L55.6 96.7L57.3 97.7L58 95.6L56.4 94.7L57.2 93L55.2 89.9L60.5 86.9L61.2 84.2L63.1 85.2L64 82L57.1 75.1L54.5 73.9L52.4 70.1L53.6 67.8L50.9 65.6L50 62.8L51.9 64.7L53.3 61.8L55.8 61.1L56.8 61.8L60.2 58.7L61.1 59.2L61.5 57.9L64.1 57.7L68.4 55.5L67.3 54.5L67.7 53.5L69.8 54.1L71 56.5L72.6 54.6L73.6 50.6L72.9 48.2L71.1 47.4L71.3 45.7L70 43.7L71.3 40.6L71 38.2L69.5 36.2L70.4 33.7L66.2 30.4L68.8 24.7L67.4 18.1L63.8 16.8L64.6 15.8L63.3 14.5L63 12.3L60.9 13.4L58.3 11.2L57.4 8.6L56 9.9L53.6 9.4L52.9 11.2L52.2 11.1L52.6 9.4L56.9 8.6L53.5 8.1L49.9 12.2L50.4 13.4L49.9 12.2L46.6 12.5L44.9 16L43.3 15.6L42.7 14.4L40.3 15.2L39.2 14L42 11.9L42.1 9.2L38.6 10.4L35.5 8.6L34.3 10.3L34.3 7.9L31.9 8.2L33.4 7.1L32.8 3.9L31.8 4.2L30.1 2.5L28.6 3.6L25 2.2L20.4 2.5L19.9 1.3L20.9 0.1L20.2 0.1L19.3 4.5L19.8 2.5L22 2.5L22.8 5.2L21.4 6.1L23 4.7L24.9 7.2L23.6 8.2L25.2 7.8L21.7 10.3L22.2 11L24.8 10.3L23.6 12.1L25.3 13.4L25 14.4L23.8 14.1L24.3 15.5L27.4 16.3L29.5 19.8L31.8 21L29.4 20L27.4 16.6L23.5 17L22.6 16.2L20.9 18.7L21.2 23.2L21.6 21L19.7 19.8L18.9 20.6L19.3 22.7L17.7 22L18.5 20.8L17.1 18.5L11.6 18.9L9.8 20.2L9.1 23.6L11.9 23.9L12.5 25.2L11 23.9L9.5 32.9L7.1 32.8L6.5 34.1L7.1 35.3L9 35.2L9.6 38.2L6.6 40.6L7.7 41.5L7 42.6L4 43.5L2.3 42.5L2.4 43.4L0.7 43.4L2.9 49.1L1.6 51.2L2.5 52.2L0 53.6L1.8 55.2L1.2 57.4Z', w: 73.6, h: 100, b: [5.87, -66.24, 15.04, -53.78] },
DJ: { d: 'M81 69.5L79.4 67.8L79.9 67.3L78.8 67.3L79.4 66.7L77.8 66.2L77.8 67.3L77.2 65.6L76.1 65.6L76.7 64.5L75 62.3L75 60.6L74 61.2L74.5 62.3L74 61.7L74.5 62.3L72.9 62.9L72.9 61.7L72.3 62.9L71.2 61.7L64.7 61.7L60.4 62.9L58.7 61.7L58.7 62.9L56 62.3L54.4 64L53.3 63.4L50.6 65.1L49.5 64.5L48.4 66.2L50 67.3L50 68.4L47.3 68.4L46.2 69.5L40.8 65.6L40.8 62.3L43 62.3L44 64L49.5 64L50.6 59.5L53.3 57.3L54.9 53.4L57.1 53.4L59.8 51.2L64.2 52.3L65.8 50.1L70.1 50.1L70.7 48.4L71.8 48.4L76.7 42.3L83.2 41.7L83.2 40.6L87.5 40.1L89.7 34.5L89.2 27.3L87 24.5L86.5 18.4L84.3 14.5L84.8 12.8L83.7 12.8L83.7 14.5L82.7 13.9L79.9 8.9L75 3.3L74.5 0L65.3 3.3L62.5 5.6L60.9 5L59.3 7.2L59.3 8.9L57.1 9.5L56 11.1L56 13.4L57.1 13.4L57.6 15.6L53.8 16.7L54.4 17.3L53.3 17.3L53.3 18.4L51.1 18.4L50.6 20.1L41.9 11.7L37.5 10L31.5 17.3L29.9 22.3L25.6 25.6L20.7 35.1L9.8 50.1L7.1 51.2L6.5 52.9L3.3 55.1L0 67.3L2.2 76.7L1.1 96.1L2.7 97.2L9.8 98.3L9.2 99.4L10.9 100L10.9 98.9L16.3 98.9L16.9 97.2L20.1 96.1L23.4 96.1L23.4 97.2L30.5 95L35.3 96.1L39.7 92.8L45.7 91.1L46.8 89.5L49.5 91.7L53.8 90.6L55.5 95.6L62 96.7L65.8 94.5L81 69.5Z', w: 89.7, h: 100, b: [41.77, -12.82, 43.42, -10.98] },
DK: { d: 'M74.4 64.6L75.3 63.7L75.7 64.5L76 63.9L75.1 62.9L74.4 63L75.7 61.4L75.1 61.3L75.6 59.6L74.8 59.4L76.4 58.4L75 57.7L77.1 57.7L77.2 56.7L76.4 56.6L77.9 56.6L78 53.9L79 53L78.7 54.1L79.4 54.6L79.9 53.5L80.2 54.2L81.3 51.8L78.4 50.5L77.8 51.6L78.5 49.7L76.1 50.2L78.5 49.6L78.4 47.4L77.1 46.6L73.5 47.7L73.3 50L73.9 49.7L74.2 51L73.2 50.1L72.8 50.9L73.3 49.6L72.9 48.8L71.1 50.3L71.4 51.5L70.6 50.8L69.4 50.9L69 49.6L68.6 50.9L68.5 55.2L69 54.6L69.8 56.2L68.9 56.8L68.6 55.4L68.4 58.9L69.5 59.6L69.4 58.5L70.9 60L70.9 63.9L74.4 64.6ZM87.3 54.3L85.2 55.6L86 55.6L86.3 58.1L85.9 57.7L85.6 58.1L86.2 57.5L85.6 55.8L85.2 57.9L84.3 57.6L84.9 57.4L84.2 57.1L84.9 55.5L82.6 55.3L83.8 56.5L82.6 57.5L80.9 57.4L81.9 57.9L81.1 58.1L82.1 58.5L82.4 60.1L81.8 60.5L82.6 61.7L85.1 62.1L85.1 62.9L84.2 62.6L85.4 63.2L84.6 63.8L85.7 66.6L86.7 64.5L85.4 63.3L86.7 63.3L86.1 61.9L88 61.1L86.8 59.3L88.2 58.2L88.5 58.9L88.9 58.6L88.7 57.7L88.4 58.2L88.7 55L87.3 54.3ZM79.6 41.1L76.7 42.4L74.2 46.1L70.7 46.3L69.1 49.7L69.2 49.1L70.6 50.7L70.7 49.8L69.9 49.8L70.4 49.8L71.1 47.7L73 46.9L73.8 47.3L76 46.4L78.5 47.4L79.4 45.4L78.9 42.4L79.6 41.1ZM79.7 58.3L79.8 59.4L79.1 59.8L79.5 59.2L78.5 58.4L75.6 59.2L76.9 62.2L77 61.7L77.7 62.2L77.3 62.6L79.4 63L80.5 62.5L80.3 60.9L80.8 61L79.4 59.8L80.3 59.5L79.7 58.3ZM82.6 63.6L81.6 63.9L82 64.6L81.3 64.5L81.4 65.1L83.7 66.4L85.1 66L84.8 64.2L83.6 64.6L82.6 63.6ZM98.3 61L97.9 62.5L99.7 63.3L100 62.2L98.3 61ZM0.4 0.3L0 1.6L2.3 3.8L0.4 0.3ZM1.2 0L0.6 0.4L2.5 2.7L2.5 1.8L3 2L2.3 1.4L2.8 1.3L1.8 0.4L1.4 1L1.2 0ZM72.1 47.7L70.3 49.3L71 50L72.1 47.7ZM75.9 62.6L75.4 62.9L76.4 63.5L76 64L77.3 64.2L75.9 62.6Z', w: 100, h: 66.6, b: [-7.26, -80.31, 15.16, -65.37] },
DM: { d: 'M13.1 0L4.4 0L4.4 2.3L2.2 4.6L2.2 6.8L4.4 6.8L2.2 6.8L4.4 9.1L4.4 11.4L0 11.4L0 13.6L4.4 13.6L4.4 20.5L2.2 20.5L2.2 22.7L0 22.7L0 25L2.2 25L2.2 36.4L4.4 36.4L4.4 38.7L6.6 40.9L6.6 45.5L8.8 45.5L8.8 47.8L11 50L11 56.8L13.1 56.8L17.5 61.4L17.5 65.9L19.7 65.9L19.7 70.5L17.5 70.5L17.5 72.7L19.7 72.7L19.7 79.6L21.9 79.6L21.9 81.8L24.1 81.8L24.1 84.1L21.9 84.1L21.9 88.6L24.1 88.6L24.1 93.2L26.3 93.2L26.3 95.5L24.1 97.7L26.3 97.7L26.3 100L28.5 100L28.5 97.7L32.9 97.7L32.9 95.5L37.2 95.5L37.2 90.9L46 90.9L48.2 88.6L48.2 84.1L50.4 84.1L50.4 81.8L52.6 81.8L52.6 79.6L50.4 79.6L52.6 79.6L50.4 77.3L52.6 77.3L52.6 68.2L50.4 68.2L50.4 61.4L52.6 61.4L52.6 59.1L50.4 59.1L50.4 56.8L52.6 56.8L52.6 54.6L50.4 54.6L50.4 47.8L48.2 47.8L50.4 45.5L50.4 43.2L52.6 43.2L52.6 40.9L50.4 40.9L52.6 40.9L52.6 38.7L50.4 38.7L50.4 31.8L48.2 31.8L48.2 29.6L50.4 29.6L50.4 27.3L46 27.3L46 22.7L41.6 22.7L41.6 20.5L39.4 20.5L39.4 13.6L37.2 13.6L35.1 11.4L35.1 13.6L32.9 11.4L30.7 11.4L30.7 9.1L30.7 11.4L26.3 11.4L26.3 9.1L24.1 9.1L24.1 11.4L19.7 11.4L19.7 9.1L17.5 9.1L17.5 6.8L15.3 4.6L15.3 2.3L13.1 2.3L13.1 0Z', w: 52.6, h: 100, b: [-61.49, -15.84, -61.25, -15.38] },
DO: { d: 'M6.8 54.6L9.2 56.3L9.5 58L9 58.3L10.3 60L10.3 60.9L9.5 60.9L9.5 61.7L8.7 62L9.2 62.6L12.8 62.3L16 66.9L18.5 61.2L25.3 53.2L25.8 50.9L24.5 47.7L25.8 46.3L27.4 46.9L28.3 48.3L29.3 48.3L31.8 45.4L31.8 46L32.6 45.4L34.2 45.7L35.6 44.6L35.9 43.2L37.2 43.2L38.3 43.4L39.1 44.9L38.6 47.7L40.8 49.2L39.7 49.5L39.7 48.9L39.1 49.7L41.3 50L43.2 48.9L50.5 48.9L54.1 43.4L56.3 43.2L57.9 41.7L57.9 42.3L63 42.6L63.6 44L64.7 44L64.9 42.6L67.1 44L72.8 43.4L73.1 42.6L73.6 43.2L76.1 42.6L76.9 43.7L79.1 44.3L84.5 44L87 46L88.3 49.7L88.6 49.2L89.1 49.2L88.9 49.7L91.6 49.5L91.6 47.7L92.7 46L92.1 44.9L96.2 45.7L96.7 43.4L100 39.1L100 37.7L92.7 32L89.9 28.8L84 25.9L81.3 26.2L81.3 27.4L79.9 27.4L78.3 25.7L77.4 25.7L78 25.7L78 27.1L75.8 25.9L72.6 25.7L70.9 23.9L69 23.9L69.8 23.9L69.6 24.8L68.2 24.2L64.7 24.5L64.1 23.6L64.9 20.2L70.7 21.6L71.2 21.1L75.8 21.6L75.8 20.5L77.4 19.3L77.7 18.2L74.7 18.5L76.1 16.7L75.5 16.2L72.8 18.8L69.8 17.3L66.8 17.3L66 18.2L61.7 18.8L58.7 15.9L57.9 13.9L58.2 12.1L57.3 11.5L57.9 9.2L56 7.2L53.3 7.2L51.1 9L47.3 8.4L42.1 4L40.2 5.2L39.4 4.6L37.5 4.9L34.2 2.6L33.4 2.9L31.8 0.6L29.3 0.3L29.1 1.2L27.7 0L26.4 0L25.3 0.6L25 1.7L22.6 2L23.4 2L22.8 2.6L21.7 1.7L20.9 2.9L16.8 2L14.4 0.6L11.1 0.6L10.6 1.4L9.5 0.9L9 2.6L7.1 3.2L6.3 4.3L6.5 4.9L7.1 3.8L7.6 6.6L8.2 6.6L7.1 6.4L6.8 7.2L7.6 8.1L7.3 10.1L8.7 12.1L8.7 16.4L7.6 16.2L6 17.3L7.1 19L7.9 18.8L8.4 19.9L10.1 20.2L10.6 21.1L9.8 21.6L10.3 21.9L9.8 21.9L9.8 23.4L8.4 23.6L7.9 25.4L7.1 25.4L6 27.1L5.7 26.5L4.9 27.1L4.9 26.5L3.3 28L3.5 28.5L4.6 27.7L6.3 28L6.5 29.4L7.9 30.3L7.3 34.8L5.4 35.7L5.4 37.4L1.4 36.6L0 37.4L3.5 41.2L2.7 41.4L3 42.3L8.4 45.7L6 50L7.3 51.5L6.8 54.6Z', w: 100, h: 66.9, b: [-72.01, -20.34, -68.33, -17.88] },
DZ: { d: 'M83.7 0.9L82.7 1.2L81.7 0.8L79.9 1.5L79.5 1.3L79.6 0.7L78.8 0.8L77.7 0.1L76.8 0L77.1 0.5L76.7 1L75.4 1.3L75.1 0.8L73.8 0.7L73.6 0.2L73 0L72.2 0.4L71.9 1.2L69.5 1.6L67.9 2.7L67 2.6L66.5 2.2L66.7 1.9L65 1.2L60.7 1L58.9 1.9L57.6 1.6L57.3 2.1L55.9 1.7L55.5 2.4L54.4 3L53.1 2.7L47.2 3.6L42.9 5.9L41.5 7.8L40.7 7.7L40.1 7.1L38.9 8.3L38.1 7.9L37.4 8.2L36.1 9.1L35.7 10.4L34.4 10.9L33.4 11.8L31.2 12.2L33.5 14L33 14.8L33.8 15.5L33.3 16.2L34 17.7L33.9 19.7L33.5 20.1L34.3 20.6L33.9 22.6L34.8 23.7L34.5 24.4L35.2 25.6L37.1 26.9L35.9 28L35.7 29L36.2 28.9L36.4 29.3L34.2 29.4L32.3 28.9L27.9 29.2L28.3 31.1L24.2 32L24.2 33.4L23.8 33.6L23.6 34.3L23.7 35L24 34.6L24.1 35.1L24.3 34.7L24.8 35.3L24.2 36.4L24.4 37.3L20.9 38.3L19.7 39.6L16.4 41.7L15 44L14.1 43.9L14.3 43.4L13.9 43.3L10.8 43.6L9.2 44.2L7.5 43.8L6.4 44.6L5.1 44.7L5 45.1L0 48.4L0 56L24.5 72.5L44.9 86.9L47.6 88.9L47.6 90.9L49.7 91.6L50.6 93.1L51.2 93.5L52.7 93L53.6 94L53.5 94.5L53.8 94.2L54.3 94.5L55 94.2L55.2 94.7L57.7 95.5L57.8 97.6L57 99L58.2 100L70.1 97.6L78.1 90.2L100 76.4L99.1 75.9L97.5 73.4L97.9 72.5L97.2 72.7L94.4 70.8L94 71.1L93.8 70.8L92.1 71L91.3 70.4L90.5 68.7L90.5 66.6L87.4 62.1L87.9 61.1L89.6 60.3L89.9 59.6L90 58.5L89.3 56.4L89.7 54.7L89.5 54.6L90 54.1L90.2 52.9L89.5 50.7L89.8 47.7L89.4 45L87.5 41L87.4 40.3L88.2 39.9L85.9 29.4L82.4 27.1L82.2 25.2L81.3 23.7L79.7 23L78.3 19.5L78.4 18L79 17.2L79.7 17.2L79.9 16L81.7 15L82.2 14.1L81.9 12.9L83 11.1L82.1 10.7L82.5 9.6L81.9 7.2L82.6 4L81.4 3.6L82.8 2.8L82.7 2L83.9 1.6L83.7 0.9Z', w: 100, h: 100, b: [-8.67, -39.99, 12, -19.33] },
EC: { d: 'M77.9 0.1L77.5 0.1L77.5 1L78.1 1.2L77.1 1.3L77.4 1.9L76.9 2.3L77.2 2.4L76.7 2.5L76.8 2L76.4 1.5L75.7 2.3L74.2 2.3L72.9 2.9L72.9 3.4L72.8 2.9L71.5 3.7L70.1 4.1L70 4.8L70.7 5.7L70.3 5.9L71 7.9L70.7 8.1L70.4 6.6L70.1 8.8L67.6 11.2L68.2 12.7L69 12.9L67.9 12.6L67.2 14.5L65.6 14.6L65.1 15.2L66.2 17L65.5 18.7L66.2 20.3L66.2 21.3L65.3 22.3L64.5 22.1L65.3 23.1L66.7 23.5L69.1 25.5L68.9 24.7L69.2 25L69.7 24.6L69.5 24.3L69.8 24.5L70.1 24.1L70.1 23.5L70.9 23L70.9 22.3L71.2 22.3L71.3 22.9L70.7 23.2L70.4 24.1L71.1 24.1L71.1 23.5L71.4 23.9L70.8 24.3L71.4 24.5L71.7 23L71.4 22.4L71.7 22.4L71.9 24L72.1 24.2L72.2 23.8L72.2 24.3L72.5 23.9L72.4 24.8L71.5 26.5L71.2 28.2L70.5 29L70.7 29.4L69.2 30.1L69.9 32.7L68.9 33.4L68.3 33.1L67.8 33.5L67.9 34.5L68.8 34.5L67.9 36L68.7 36.1L69.6 35L71.3 35.6L71.8 36.2L72.9 35.9L73.8 36.4L74.3 38.3L75.1 39.2L76.6 39.5L77 38.7L77.5 38.7L77.3 38L77.6 37.3L78.9 36.8L79.4 33.2L80.4 31.9L80.7 29.4L81.8 30.2L82 29.9L81.7 29.3L83.9 27.1L91.2 24.6L94.6 21.9L97.7 18.3L98.8 14.4L99.3 14.9L100 14.8L99.5 13.3L99.7 12L98.8 11.5L97.9 9.9L97.4 10L97.4 9.5L98.7 9.9L99.6 9.6L97.6 9L97.3 8.5L96.2 8.5L95.1 7L93.4 6.2L92.6 6.5L92.5 7.4L91.5 7.5L90.6 7.1L90.4 7.4L88.5 7.1L88 6.5L85.7 6.3L86 4.8L84.9 4.5L84.9 3.8L83.1 3.8L81.7 3.1L80.9 1.5L80.3 1.9L77.9 0.1ZM1.6 7.9L0 8.9L1.2 9.1L1.3 10.4L3.2 12.6L1.7 13L0.5 14.4L1.2 15.1L2.7 15.2L4.5 14.4L5 13.4L3.8 12.4L4 11.4L2.6 10.2L2.5 9.1L1.6 7.9Z', w: 100, h: 39.5, b: [-91.6, -1.45, -75.19, 5.03] },
EE: { d: 'M97.6 6.8L95.3 8.7L87.6 7.1L79.8 7.4L75.5 4L73.2 5L65 1.2L64.9 3.1L63.3 1.6L62.4 3.7L60.7 0L60.3 3.7L57.5 0.9L58.5 4.3L56.6 5.9L54.1 4.6L52.8 5.6L52.2 4.3L52.2 6.5L51.1 5.3L48.7 5.9L46.4 3.4L46.4 7.4L45.1 5.9L44.5 7.7L39.8 6.2L39.3 8L36.8 8.7L37.8 10.5L34.5 9L35.4 12.7L30.9 12L29.8 13.9L26.2 13.9L26.8 18.2L25.1 18.5L24.6 20.3L27.3 21.8L27.9 20L28.8 21.8L25.1 22.7L26.6 26.1L25.2 28.2L30.1 27.3L30.6 28.5L26.5 28.8L26.5 33L27.6 33L27.3 34.8L29.3 35.4L28.8 38.1L30.3 41.1L31.5 40.2L32.4 41.7L34.2 41.4L35.9 44.1L38.2 42.9L38.9 39.6L42.8 40.2L39.7 54.7L42.3 52.4L49.8 50.6L51.4 48.5L52.8 48.2L54.4 51.2L54.4 48.5L60.3 53.9L61.6 53.3L62.5 55L66.3 55.3L65.7 57.7L68.5 59.2L69.7 63L72.7 63.3L73.2 65L75.1 65.3L79.5 61.8L86.5 65L87 60.3L89.7 58.9L88.7 57.1L93.9 55L93.9 53.9L91.7 53.3L92 50.9L90.8 50.6L90.9 47.9L88.7 44.4L89.7 39.9L86.7 27.3L92.8 21.5L95.3 13.3L98.9 12L100 9.3L97.6 6.8ZM11.6 31.8L10.2 33.6L7.1 33.9L7.4 36.3L5.8 34.5L4.4 38.4L2.8 35.7L0.2 36L3 40.2L1.6 40.2L1.9 41.7L0.6 40.8L1.3 41.7L0 42.6L2.8 43.5L3.6 45.6L6.1 46.8L2.4 51.5L3.4 53.9L6 51.2L7.5 44.7L11 43.5L14.1 44.7L13.8 42.6L15.7 43.2L16 40.8L17.2 42L17.2 39.6L19.7 39.6L19.3 38.4L20.8 38.1L20.8 36.6L21.6 38.1L22.6 36.6L22.9 38.1L23.5 37.5L17.4 32.4L15.2 32.1L14.3 33.9L11.6 31.8ZM11.9 18.2L8.8 23L3.3 22.7L5.2 24.5L8.6 23.9L10.7 30.3L13.2 30L14.3 26.7L15 28.5L16.6 27.6L16.5 26.1L15 27.3L14.6 26.4L19.4 25.8L17.4 21.2L13.6 20.6L13.5 18.5L11.9 18.2Z', w: 100, h: 65.3, b: [21.83, -74.82, 28.21, -70.65] },
EG: { d: 'M4 0.1L1.4 2.8L1.5 5.6L2.9 9.3L2.1 12.5L0 15.7L1.9 21.5L1.4 22.3L2.7 25.2L2.7 97.3L59.3 97.3L59.8 95.8L60.8 95.2L61 95.5L60 97.3L75.9 97.3L79.3 100L83.3 99.5L84.7 95.4L89.5 94.4L91.9 89L94.2 89.6L97.9 86.2L96.6 82.7L96.5 78.5L97 78L97.3 78.2L96.9 78.5L99.4 78.7L98.7 77.8L97.7 77.7L97.6 77L96.6 76.5L95.5 74.7L93.6 72.8L93.8 72.3L88.4 60.8L87 59.3L83.5 51.9L82.7 51.3L83.3 49L81.8 47L82 45.5L80.7 44.9L78.8 41.6L79.7 41.4L78.6 39.8L79.4 39.8L79.9 40.4L79.7 39.4L78.6 37.9L76.5 36.5L74.6 33.1L73.1 32L72.7 30L71 27.9L71.4 26.6L70.8 24.2L68.5 21.7L70 18.9L69.6 18.2L70.7 17.7L70.2 14.9L69.4 15L68.1 13.9L68.6 13.2L67.8 11.4L68.5 10.3L68.1 5.6L68.5 3.9L67.3 4.1L66 3.3L64.9 1.6L64 1.5L63.4 2.3L61.5 2.4L59.1 1.2L56.7 0.7L52.4 2.2L50.8 2L51.6 3.5L50.7 2.1L50.3 3.5L49.4 4.1L48.3 4.1L48 3.6L43.1 7.6L40.3 8.8L38.7 8.9L36.4 7.1L33.5 6.1L28.8 6L28.3 4.4L27 5.1L24.6 4.8L23.7 3.2L22.2 3.3L22.7 3.2L10.9 0.4L5.5 1.8L4.2 1.5L4 0.1ZM85.8 4.7L85.2 3.7L83.3 4.9L79.8 5.9L76.8 4.8L78 5.3L77.7 5.9L75.5 6.6L75.8 6.2L75.2 5.9L75.8 5.9L75.2 5.1L73.9 6L75 5.8L74.5 6.5L73.7 6L71.7 6.7L73.8 5.6L70.7 6.4L68.7 4.4L68.2 5.9L68.6 10.1L68.2 11.7L69.5 14.5L70.5 15.2L70.4 18.2L71.7 20.2L71.9 23.1L72.1 22.8L72.8 23.5L73.3 25.4L76 27.6L76.5 32.3L76.7 32L78.2 34.3L79.9 35.5L81.3 37.8L85.6 40.8L85.2 40L85.7 40.1L87.3 37.9L87 34.6L89 30.5L90 24.5L91.4 22.7L91 19.9L89 14L88.2 13.2L87.8 10.4L85.8 4.7Z', w: 99.4, h: 100, b: [24.7, -33.42, 35.79, -22.26] },
EH: { d: 'M100 0L46.6 0L45.1 4.5L43.8 6.8L43.5 7.6L43.7 8L43.4 8.1L43.4 8.7L42.9 9.5L42.7 10.7L42 12.4L40.8 13.6L37.7 15.3L37.1 16L35.7 16.5L34.6 16.5L33.7 18L33.1 18.5L32 18.6L31.1 19.8L30.8 20.5L31 21.9L30.7 22.9L29.8 24.7L29.2 25.2L28.9 26.6L28.6 27.2L28.7 27.6L28.5 28.4L27.5 29.6L27.4 30.5L26.9 31.4L27 31.9L26.7 32.7L26.7 33.6L27 34.6L27 35.6L26.9 36.8L26.3 38L26.3 39.3L25 40.2L24.9 41L24.6 41.4L22.9 42L20.8 44.7L20.2 45L19.2 45.9L19.1 46.3L18.7 46.4L18.4 47.1L18 47.2L17.9 47.9L15.8 49L15.1 49.7L14.2 50.6L13.9 51.4L14 51.5L13.5 51.9L13.5 52.3L12.9 52.9L13.3 53.2L13.3 52.8L13.8 52.8L13.5 52.7L14 52.3L14.1 51.4L15.1 49.9L15.4 49.9L15.8 49.4L15.8 49.8L15.9 49.5L16.5 49.8L16.1 50.2L16.1 50.6L16.6 50.6L15.4 51.9L14.9 52.9L14.5 53.2L14.2 54L13.3 54.7L13.6 55.1L13.3 56L12.7 56.8L12.2 57.1L12.3 57.4L11.6 58.4L11.6 58.9L10.7 59.9L10.6 60.3L11 60.2L11.4 60.7L11.4 61.3L10.4 62.5L9.6 62.5L9.6 63.6L9 64.5L8.8 66.8L7.9 67.5L7.6 69.2L7.1 69.8L7.2 69.9L5.8 70.7L5.5 70.7L5 70.3L3.8 71.5L3.9 71.9L3.3 72.4L2.4 74.9L2.4 75.6L1.7 76.3L1.5 77.1L1.8 77.1L1.8 77.4L1.4 77.7L1.5 78.8L1.3 79.3L1.4 79.8L0.6 84.1L0.7 84.9L0.5 85.5L0.5 86.9L0 87.9L0.2 88.2L0 89.1L0.2 89.1L0.4 89.6L0.6 89.8L0.4 88.2L1.8 82.7L48.6 82.7L47.9 69.4L47.4 66.3L46.9 64.4L47.3 63L47.3 62.1L49.3 60.5L53.6 57.8L56.1 57.2L58.8 55.9L60.5 55.5L60.5 22.2L100 22.2L100 0Z', w: 100, h: 89.8, b: [-17.1, -28.81, -8.67, -21.24] },
ER: { d: 'M100 82.1L97.8 78.3L97.3 78.6L97.9 80.3L96.6 81L96.9 80.6L95.5 79.7L94.5 80L94 77.1L93 76.9L91.2 74.2L90.3 74.9L88.8 74.6L88.4 71.5L86.6 68L85.4 67.6L86.4 69.2L85.2 67.7L84.5 67.9L82.6 64.6L78.4 63.4L77.8 61.7L70.9 52.7L67.8 52.2L66.6 51.1L64.7 51.4L64.4 49.3L63.5 48.8L63.9 48.5L62.4 48.6L62.7 47.6L61.8 47.9L61.1 46.6L61 47.7L59.8 47.3L60.1 46.8L59 47.7L58.9 46.9L57.8 48.5L56.2 47.7L54.5 45.7L53.9 44L54.5 43.7L53.5 43.2L54.5 41.9L51.4 39.1L50.7 40.3L50.5 39.8L50.8 40.8L50.4 40L49.9 40.8L49.9 42.8L51 43.7L50.7 45.4L50.1 46L48.9 45.6L49 43.1L47.7 39.7L46.8 38.6L45.2 38.8L44.9 34.6L42.8 33L42.3 31.8L40.5 20.6L37.4 10L32 0L30.1 1.9L28.9 5.2L27.6 5.9L27.1 7.4L25.3 6.9L24.9 8L24.3 7L23.4 7.2L23.1 8.4L21.9 8.9L20.6 8L19.8 10L16.1 10.6L16.1 12.8L14.5 15.2L13.3 14.4L11.6 14.8L8.6 14.4L8.6 19.8L7 20.8L6.4 23.1L6.9 24.5L8 24.8L7.5 25.7L7.9 27.1L6.9 28.1L1.6 43.4L0 44.3L1.9 58.3L4.5 57.4L9.5 58.3L10.4 56L13.1 55.4L14.3 56.7L14.3 57.7L16.4 59.6L22.1 48.6L24 51.3L27.3 51.9L30.1 56L36.8 54.7L38 54L38.7 52.5L39.9 52.3L40.8 52.3L41 53.4L41.9 53.7L41.9 56L43.7 55L43.8 54L46.3 54.8L45.9 53.7L46.9 53L49.8 53.9L52.3 56L55.1 53.9L56.3 56.3L57.1 56L67.2 61.1L71.8 68.3L77.6 71.7L83.9 80.7L86.3 81.2L89 85.8L89.9 84.9L91.1 85.3L93.4 87.6L95.4 86.4L94.9 85.2L96.3 83.5L100 82.1Z', w: 100, h: 87.6, b: [36.43, -18.32, 43.14, -12.45] },
ES: { d: 'M31.4 79.1L32.6 76.5L37 75.6L38.7 73.5L50.6 73.2L52.3 73.9L54 72.3L56.8 73.4L58.7 71.2L59.4 68.5L62.1 65.9L68.1 64.7L66.9 63.6L69.7 57.4L75.6 53.3L72.4 50.7L71 46.1L78 34.4L80.7 33.1L79.2 31.9L81.7 29.4L90.3 27L91.5 25.2L97 22.4L99.1 20.4L98.3 17.1L99.9 15.8L97.1 14.2L93.8 15.8L91.6 14.7L89.6 15.6L87.6 14L85.4 14.8L84.5 11.6L79.5 10.1L79.1 12L73.6 12.1L71.2 10.2L69.2 11L67.7 8.9L63.7 8.2L63.6 7.3L63 8.3L62.1 8L62.6 5.7L60.9 5.9L59.5 4.4L55 5.4L51.9 3.6L50.3 3.8L49.8 5.1L46.4 3.8L46.2 4.5L46.5 3.5L45.6 3.1L43.5 4.3L43.4 3.2L38.2 4.5L28.6 2.7L27.4 1.4L25.5 2.7L18.5 2.4L17.8 3.4L17.9 2.5L16.1 2.3L14.5 0.7L13.5 1.4L12.8 0L7.8 2.4L7.5 3.6L9 3.2L7.8 3.7L9 4.2L8.6 5.1L7.8 4.2L4.7 5.6L3.6 4.9L2.5 5.6L2.9 6.2L1 6.6L0.2 9.9L0.9 9L1.4 9.6L1.7 11.4L3.4 10.5L1.7 13.3L2.3 13.8L3.6 12L4.4 12.3L3.8 14.5L2.8 14.4L3.6 15.3L5.2 14.7L3.4 16.7L5.5 15.6L3.2 18.2L3.3 20.7L5.2 18.8L8.7 17.8L9.7 18.8L8.6 20.2L9.2 21.4L11 20.1L11.3 21L12.7 20.3L15 21.4L16.7 20.6L17 19.5L21.5 19.7L22 23L24.6 23.8L22.3 27.1L18.8 29.6L19.8 31.3L20 36.6L18 38.4L19.3 40.3L18.1 43.7L13.9 43.9L15.9 46L17 49.5L18.5 50L17.9 52.1L16.2 53.4L15.6 56.4L17.5 59L18.8 58.6L18.2 60.6L16.2 61L14.1 65.4L15 69L18.6 69.1L18.4 68.1L18.7 68.7L19.3 67.9L19 69.2L22 70.9L23.2 72.8L23.4 71.8L22.7 73.5L24.8 75.5L24.2 76.1L23.7 75.3L25.8 78.8L29.2 80.6L30.7 79.9L30.7 78.8L31.4 79.1Z', w: 100, h: 80.6, b: [-9.3, -48.81, 3.32, -38.63] },
ET: { d: 'M62.7 16.6L61.5 14.6L60.4 14.4L57.6 10.3L55 8.8L52.9 5.6L48.4 3.3L48.1 3.4L47.5 2.3L46.3 3.3L45.1 2.3L43.9 1.9L43.4 2.3L43.6 2.8L42.5 2.4L42.4 2.8L41.6 3.3L41.8 2.8L41.6 2.3L41.2 2.1L41.1 1.7L40.7 1.7L40.2 1.7L39.9 2.4L39.3 2.7L36.3 3.3L35.1 1.4L33.6 1.2L32.7 0L30.2 4.9L29.3 4.1L29.3 3.6L28.7 3L27.5 3.3L27.1 4.3L26.7 4.4L26 3.9L23.8 4.1L23.1 6.3L23.3 7.3L21.9 10.4L20.9 13.3L21.2 13.7L21.1 15L20.1 14.9L17.9 15.3L15.1 20.2L13.9 21.1L13.6 21.7L13.9 22.1L13.7 23.8L12.9 24.9L13.3 25.3L13 27L13.3 27.4L12.5 27.9L12.5 28.4L11.9 28.6L11.9 28.2L10.7 27.2L8.6 29.5L9 31.9L8.9 32.6L8.2 33.1L8.1 34.1L7.3 36.4L7.7 42.8L6.9 43.6L6 43.6L5.9 44.1L5.2 44.4L4.7 44.4L4.3 43.7L1.5 43.9L1.3 44.1L1.5 44.6L1.1 44.9L1.4 45.3L1.1 45.5L1.3 46L0.5 46.4L0 47.2L0 47.8L0.3 48.3L1.7 48.4L2.1 48.8L2.9 48.5L3.5 48.6L3.6 49L4.5 49L5.3 49.8L6 50L6.9 51.2L6.8 52L8 52.8L7.9 53.4L8.2 53.6L8.7 53.8L8.6 54L8.9 54.2L9.8 54.2L10.2 54.6L10.2 55.3L11 55.4L11.8 56.4L12.5 56.2L13 56.6L13.5 57.2L13 58.7L13.3 61.1L14.2 62.4L14.2 62.9L15.4 63.7L15.3 64.7L17.3 64.2L19.1 64.9L18.7 68.4L19.7 69.5L19.8 70.2L20.3 70.6L25.7 70.7L26.9 71.1L33.4 75.5L34.1 76.3L36.5 76.3L36.9 76L37.1 76.3L37.9 76.2L38.1 76.6L39.2 76.9L40.5 76.7L41.3 77.2L42.2 77L42.2 77.3L43.3 77.3L43.7 77.7L43.9 77.2L45.1 76L45.9 74.5L51.7 71.8L54.5 74.1L58 73.8L59 74L60.5 72.5L63.7 72.3L65.7 71.8L67.2 69.6L71.1 67.9L73.3 67.3L79.8 67.6L86.6 60.3L100 46.9L93.3 46.9L73.3 40.2L70.9 37.7L69.7 37.4L69.3 36.4L68.6 35.9L68.4 34.5L67.2 34L66.7 32.8L65.7 31.9L65.3 30.3L64.5 29.3L66.5 26.6L65.3 26.7L65.1 26.1L64.5 26.2L64.2 25.9L62.8 26.8L60.9 26.8L59.8 27.2L58.6 26.8L58.7 24.4L58.5 23.2L58.9 21.7L59.7 21.1L62.7 16.6Z', w: 100, h: 77.7, b: [33, -15.06, 48, -3.4] },
FI: { d: 'M0 12L6.1 18.4L10.2 20.4L10.6 22.2L12.6 24.1L11.6 29.3L13 29.9L12.2 32.6L14 36.2L12.7 42L14.6 46.9L16.4 46.2L16.7 48.1L18.3 48.1L20 51.4L19.1 51.3L19.7 52.5L19 53.1L20.1 54.2L19.9 54.9L18.9 54.3L19.7 55.9L18.4 55.1L16.2 56.1L15.2 59.6L12.8 61.7L12.5 63.4L11.6 63.1L11.7 64.4L10.3 64.5L10 65.8L9.6 64.7L8.8 65.2L9.7 66.5L8.4 66.4L7.7 68.7L7.2 67.7L7.4 70.4L5.9 71.6L5.8 70.6L4.1 70.4L4.6 72.5L3.7 72.4L3.5 74L2.3 74.7L2.3 77.4L3.6 79.8L3 81.8L5.1 86.1L3.8 85.1L4.3 88.3L3.8 89.6L3 89.5L3.7 91L3 90.1L2.7 90.9L3.6 91L3.2 92.9L4.2 94.5L5.3 93.2L4.9 94.6L5.6 94L7.1 95.4L8.5 95.1L8 96.7L10.3 95.4L9.4 97.2L11 98L9.2 98.3L10.1 99.2L10.9 98.1L9.6 100L10.8 99.8L12.1 97.6L11.7 98.8L15.3 97.7L15.7 98.7L16.3 97L18.5 97.2L20.8 95.4L21.9 96.4L21.5 95.1L21.9 96.3L22.6 95.8L21.9 94.4L24.2 95.7L24 94.4L26 94.8L26.9 93.7L29.2 94.5L42.2 79L44.8 73.7L43.4 70.9L38.3 66L40.6 63L40.4 61.4L38.6 60.1L38.7 56.5L37.3 56.4L36.8 55.1L36.9 53.8L38 53.4L36.8 51.8L37.8 48.9L37.2 48.1L38.9 47.2L34.4 35L38.5 27.3L35.6 22.9L32.9 21.6L32 17.8L33.5 14.1L32 13.6L34 12.1L33.6 10.2L35.7 7.2L34.9 4.6L31.9 3.2L30.1 0L26.4 2.1L24.1 1.7L21.7 5.1L21.2 12.6L18.6 15L17.7 17.7L13.5 14.5L12.7 16L10.6 16.8L7.4 15.8L4.4 9.5L2.2 9.7L2.1 12.2L0.7 11.3L0 12Z', w: 44.8, h: 100, b: [20.55, -99.7, 31.59, -75.08] },
FJ: { d: 'M99.5 0.3L99.3 0.6L99.6 0.6L99.5 0.3ZM100 0L99.6 0.2L100 0.2L100 0Z', w: 100, h: 0.9, b: [-180, 16.37, 180, 19.54] },
FM: { d: 'M80.5 10.6L80.3 10.8L80.4 11.2L81 11.4L81.1 10.8L80.5 10.6ZM99.9 17.1L99.4 17.4L99.9 17.6L99.9 17.1ZM0.5 0L0 0.7L0.3 0.3L0.6 0.4L0.5 0ZM54.2 9L54 9.2L54.4 9.3L54.2 9ZM55.2 8.7L55.2 8.9L55.4 8.8L55.2 8.7ZM55.1 9.1L55.2 9.3L55.1 9.1Z', w: 100, h: 17.6, b: [138.06, -9.66, 163.04, -5.27] },
FR: { d: 'M25.4 5.7L26 5.7L26 5.3L26.4 5L27.4 5.4L28 4.9L28.1 4.6L27.8 4.6L27.6 4.1L27.9 3.9L27.7 3.2L27.3 3.4L27.8 2.5L28 2.4L28.3 1.5L27.1 1.1L26.8 0.7L26.5 0.8L26.5 0.6L25.8 0L25.3 0.2L25.4 0.6L24.7 1L24.8 1.2L24.1 1.2L24.1 1L23.7 1L24 1.7L23.4 1.8L23.2 1.6L22.5 1.9L22.7 1.9L22.5 2.1L22.7 2.3L23.7 2.6L23.7 2.9L24.1 3.3L24.1 3.7L24.4 4.1L24.1 3.8L24.2 4.3L23.8 5.2L24.4 5.5L24.9 5.5L25.4 5.7ZM1.3 25.1L1 24.7L0.3 24.3L0 24.7L0.3 25.3L0 25.9L0.7 25.9L1.3 25.1ZM98.6 36.2L99.1 36.8L100 37.3L98.6 36.2ZM55.8 52.1L55.7 52.9L55.8 52.7L56.3 52.8L56 52.6L56.4 52.7L56.4 52.4L55.9 52.5L56 52.3L55.7 52.4L55.8 52.1ZM28.9 5.4L28.5 5.8L28.8 6.4L28.9 5.4Z', w: 100, h: 52.9, b: [-54.55, -59.62, 167.03, 57.49] },
GA: { d: 'M73.2 2.4L73 1.1L71 0.5L64.3 1.6L61.9 0.8L48.5 0.8L47.7 0L42.3 0.5L41.9 21L20.5 21L19.9 22.3L15.3 20.1L13.5 21.8L15 29.1L13.7 29L13.7 27.4L12.6 27.5L13.4 27.2L12.7 26.3L9.9 27.1L9.7 28.8L11 29.1L12.7 32.5L14 31.7L15.4 33.6L19.4 34.1L16.9 34.9L17.7 36.5L16.4 35L13 34.2L12.7 35.7L12.1 34.1L10.8 33.7L10.5 31.4L9.7 31.7L10.3 37.2L9.5 42.8L6.4 47.1L7.2 49.3L6.7 48.1L5.6 49.3L5.7 47.9L5.1 47.3L4.6 48.1L4.8 47.1L3.8 47.9L4.9 49.7L4.5 48.5L4.6 49.8L3 48.4L3.8 50L2.7 48.9L2.4 50.5L0 47L2.5 52.8L4.1 53.5L3 53.6L2.2 52.5L4.8 57.1L5.3 56.3L5.1 58.1L4.5 57L5.1 58.4L8.4 62.7L9.2 67.2L14.2 72.7L14.6 75.1L18.9 79.9L31.2 90L32 92L31.2 90.3L30.9 91.7L35.5 95.4L36.9 98.4L39 100L40.7 95.4L44.1 93.5L43.6 92.7L46.9 94.6L47.3 96.7L50.9 95.1L50 93.9L51.9 91.4L51.6 89.3L47.1 87.2L49.3 85.7L45 82.1L46.3 78.8L44.9 74.7L46.8 75.1L48.1 77.5L52 74.2L53.3 75.5L60.8 75.5L59.8 73.7L60.6 72.3L59.5 66.7L60.8 67.5L60.6 66.5L62.9 65.7L66 67.8L66.7 71.2L69.1 73.1L68.4 74.7L69.1 73.9L76.2 75.8L78.5 74L80.7 70.2L83.4 74.3L82.3 76.4L86.1 76.9L86.7 75L88.5 74.3L86.7 72.6L88.6 70.8L88.3 68.6L91.2 67.3L91 65.1L92 64L90.6 62.6L92.1 61.9L92.6 59.4L91.5 58.4L92.3 56.2L91 53.2L92.8 46.8L91.3 44.6L87 43.8L86.6 41.1L82.4 40.7L81.5 39.3L83.4 37.7L82.4 33.4L83.5 31.2L85.5 30.2L86.1 28L88.8 28.3L90.4 26.7L89.9 26.1L92.3 22.1L91.2 21.5L89 15.8L84.5 14L81.2 14L81 15.3L78.6 15.4L76.5 16.9L75 16.2L73.4 17.5L70.8 17.4L72.6 15.9L71.9 13.4L70.5 12.1L71.6 8.4L70.8 6.8L73 4.5L73.2 2.4Z', w: 92.8, h: 100, b: [8.7, -2.32, 14.53, 3.96] },
GB: { d: 'M25 0L24.2 0.3L24.2 0.2L24 0.3L24 0.2L23.8 0.1L23.7 0.5L23.8 0.6L23.5 0.6L23.5 0.8L23.7 1.2L23.5 1L23.6 1.1L23.2 1.2L23.4 1.5L23.2 1.5L23.2 1.8L23.5 1.7L23.3 1.9L23.5 2.1L23.3 2.1L23.4 2.3L23.2 2.2L23.3 2.6L22.9 2.6L23.4 2.7L23.1 2.7L23.3 2.9L23.7 2.5L23.5 2.4L23.8 2.6L23.4 3.1L23.3 3.8L23.5 3.7L23.2 4.5L23.4 4.4L23.5 3.5L23.7 3.2L23.6 3.8L23.7 3.6L23.8 3.7L24 3.3L23.9 3.6L24.2 3.7L23.9 3.7L24.1 4.2L23.8 5L23.7 4.9L23.8 5.3L23.9 5L24.2 5.2L24.2 4.9L24.5 5.1L24.8 4.8L25.2 4.9L24.8 5.5L25.1 6L25.4 5.8L25.2 6.3L25.4 6.4L25.2 6.6L25.5 6.9L25.2 6.8L25.2 7L25 6.9L24.4 7L24 7.5L24.5 7.4L24.6 7.8L24.4 8.2L23.6 8.6L23.6 8.8L23.8 8.9L24.3 8.7L24.5 8.9L24.3 9L24.6 8.9L25 9.2L25.7 8.7L25.2 9.4L24.4 9.4L24.1 9.6L23.9 10.2L23.3 10.6L23.6 10.8L24 10.4L24.8 10.5L24.9 10L25.4 10L25.6 10.2L26.4 9.9L26.3 9.7L26.8 10L28.1 9.7L28.4 9.2L27.6 9.1L28.1 9L27.9 8.8L28.3 8.7L28.2 8.5L28.5 8.4L28.7 7.9L28.3 7.4L27.8 7.3L27.7 7.6L27.4 7.4L27.6 7.2L27.5 6.7L27.2 6.4L26.8 6.5L27.2 6.4L27.5 6.6L27.3 5.9L27 5.5L26.5 5.4L26.2 4.1L25.9 3.7L24.6 3.4L25 3.6L25.5 3.2L25.4 3L25 3.1L25.6 2.8L26.1 1.6L26 1.3L25 1.3L24.2 1.6L24.7 1.1L24.3 1.1L24.5 1.1L25.2 0.4L25.2 0L25 0ZM22.9 6L23 6.1L23.4 5.7L23.3 5.4L23.4 5.7L23.5 5.5L23.4 5.2L23.1 5.3L23.3 5.1L23 4.5L22.1 4.8L22 5.2L21.7 5.2L21.9 5.3L21.5 5.5L22 5.9L22.3 5.6L22.6 6L22.9 6ZM0.2 98.9L0 99L0.6 99.1L0.4 99.2L1.1 99.5L1.4 100L1.6 99.9L1.3 99.2L1.1 99.3L1 99L0.2 98.9ZM22.9 0.2L22.5 0.5L22.6 0.7L22.3 0.7L22.5 1.1L22.3 1.1L22.4 1.3L23 0.6L22.8 0.6L22.9 0.2Z', w: 28.7, h: 100, b: [-38.02, -72.85, 1.77, 65.94] },
GD: { d: 'M24.9 54.6L23.1 54.6L23.1 56.4L21.3 56.4L21.3 58.2L19.5 58.2L19.5 60L17.8 60L17.8 61.8L16 61.8L14.2 63.7L14.2 65.5L12.4 67.3L12.4 70.9L10.7 70.9L10.7 74.6L8.9 74.6L10.7 74.6L10.7 76.4L8.9 78.2L8.9 80L7.1 80L8.9 81.8L7.1 81.8L8.9 81.8L8.9 87.3L10.7 87.3L10.7 89.1L8.9 89.1L8.9 92.7L5.3 92.7L5.3 94.6L1.8 94.6L0 96.4L3.6 96.4L3.6 94.6L5.3 96.4L5.3 98.2L5.3 96.4L7.1 96.4L7.1 98.2L7.1 96.4L8.9 96.4L7.1 98.2L7.1 100L8.9 98.2L8.9 96.4L10.7 96.4L12.4 94.6L14.2 94.6L14.2 96.4L16 96.4L16 94.6L16 98.2L17.8 98.2L17.8 94.6L16 94.6L17.8 94.6L19.5 96.4L19.5 94.6L17.8 94.6L17.8 92.7L17.8 94.6L19.5 94.6L19.5 92.7L21.3 94.6L21.3 92.7L23.1 92.7L23.1 94.6L23.1 92.7L24.9 92.7L24.9 90.9L26.7 92.7L26.7 90.9L26.7 92.7L26.7 90.9L28.4 90.9L28.4 89.1L30.2 89.1L30.2 87.3L32 87.3L30.2 85.5L32 85.5L32 83.7L33.8 83.7L33.8 80L32 80L32 78.2L33.8 78.2L32 78.2L32 74.6L35.5 74.6L35.5 67.3L37.3 65.5L35.5 65.5L35.5 54.6L30.2 54.6L30.2 56.4L30.2 54.6L28.4 54.6L28.4 56.4L28.4 54.6L24.9 54.6ZM65.7 1.8L65.7 0L65.7 1.8L64 1.8L64 5.5L62.2 5.5L62.2 9.1L58.6 9.1L58.6 10.9L55.1 10.9L55.1 9.1L55.1 12.7L56.9 12.7L56.9 14.6L55.1 14.6L55.1 18.2L55.1 16.4L56.9 16.4L56.9 14.6L58.6 16.4L60.4 14.6L65.7 14.6L67.5 12.7L65.7 12.7L65.7 9.1L67.5 9.1L67.5 5.5L69.3 5.5L67.5 5.5L67.5 1.8L65.7 1.8Z', w: 69.3, h: 100, b: [-61.81, -12.63, -61.42, -12.07] },
GE: { d: 'M74.4 46.1L78 42.8L79.8 43.5L81 43L82.9 44.7L85.3 44.9L84.5 46.3L86 47.7L90.2 48.7L91.2 47.9L93.2 48.3L96.3 51.1L97.2 49.9L98.7 49.9L98.8 47.9L100 46.3L98.1 44.1L94.1 42.4L93.8 40L91.8 39.6L92 36.8L93.5 36.8L95.4 34L93.2 33.4L92.6 31.8L90.3 32.4L90.2 31.4L89.5 31.8L88.9 31.2L88.3 31.8L87.5 30L85.7 30.2L84.8 28.8L83.2 28L83.8 26.4L85.3 26.2L85.7 22.4L83.1 22L82.8 21.2L79.2 21.8L76.5 17.9L74.6 18.3L73.8 17.1L72.5 17.1L71.2 19.9L70.3 17.5L67.5 16.7L66.7 17.3L67 18.1L63.9 17.7L62.7 19.5L59.6 20.1L58.4 21.2L55 19.5L55.9 18.9L56.3 17.1L53.5 15.9L52.7 14.7L47.4 13.5L45 11.6L44.9 10L43.4 9.6L42.5 8.4L40.7 8.2L39.5 9.4L36.1 7.6L35.8 6.8L33.9 7.8L32.5 7.4L30.5 8.2L29.9 7.6L28.8 8L28.1 7L26.7 8L25.9 7.2L23.9 7.6L21 5.7L20.7 4.5L18.9 5.1L17.1 3.9L16.2 4.3L14.9 3.3L13.8 3.5L13.1 2L10.1 1.2L10 0.6L8.5 1.4L3.7 0L1.2 0.6L0 4.1L3.4 5.5L4.2 8.6L4.9 9.2L5.3 8.6L7.6 9.2L8.3 10.2L12.8 10.6L14 12.5L15.5 12.2L16.9 16.3L18.1 16.1L21.2 17.3L24.2 28L25.3 28.6L24.7 29.8L26 31.8L26.3 34.6L25.4 38.2L23.3 40L23.8 40.4L23.2 40.2L22.9 41.8L24.5 42.6L25.3 42.2L26.7 43.5L28.7 41.8L30.2 42.4L32.5 42L37.3 43.3L38.5 40.4L42.1 40.4L41.5 42.4L42.3 42.8L42.9 42.2L44.7 44.7L47.5 45.9L46.4 47.1L47.4 47.1L47.8 48.7L50.2 48.1L51.1 48.5L51.4 49.7L54.4 49.3L55.7 49.9L56.8 48.9L61.8 48.3L62 47.1L64 48.1L64.8 47.5L66.1 48.5L67.2 47.5L67.3 48.3L68.2 48.3L68.4 47.3L70.1 47.5L70 48.1L71 47.5L72.5 48.1L71.2 47.1L71.3 46.1L72.8 46.9L74.4 46.1Z', w: 100, h: 51.1, b: [40.01, -48.53, 46.74, -45.09] },
GH: { d: 'M8.8 26.3L9.1 28L8.3 29.3L9.2 30.2L7.5 31.6L7.4 33.2L9.2 33.6L9.9 35L9.7 37.2L10.5 37.2L11.9 46.2L9.9 47.3L10.2 49L8.9 49.2L7.9 50.7L7.4 50.3L5.2 55.6L4.3 60.8L4.8 61.3L0.5 67.8L0.8 69.2L0 70.6L1.2 76.6L2.5 78.2L3.7 82.7L3.5 85L4.8 84.9L4.9 86.4L6.2 85.8L7.7 86.7L8.3 90.7L7.5 90.4L7.2 91.5L8.3 93.8L7.7 94.4L2.3 94L2.3 94.6L13.2 96.8L18.6 100L23.6 97.8L23.4 97.2L25.4 95.5L31.1 94.4L33.4 92.9L37.7 92.7L38.9 91.3L42.6 90.1L45.6 87.8L45.1 88.1L50.3 86.4L51.3 84.7L51.3 85.3L55.7 83.8L60.5 84.1L59.7 83.6L59.9 82.8L60.7 84.1L64.7 83.8L66 80.7L68.7 78.8L68.7 77.9L66.8 77.9L65.6 75.3L63.9 75.4L61.7 73.7L61.1 72.3L61.6 71.5L60 70.8L60.2 69.1L58.3 67.7L59 66.3L58.2 65.5L59.4 65L59.3 63.2L60.3 60.2L59.9 58.7L59 59L58.2 58L58.2 55.9L59.3 55.4L59.3 54L59.9 54.2L59.3 46.2L61.4 45L61.4 44.2L60.2 43L60.2 41.9L57.4 40.3L55.9 37.8L56.2 37L57.7 37L58.3 36L57.3 32.7L59 27.5L57.3 26.1L55.7 26.1L54.6 27.2L53.7 26.8L55 26.1L53.9 25.8L53.9 24.9L56 24.9L55.1 22.4L55.6 19.6L56.2 19.3L55.7 17.9L56.7 17.5L55.6 16.9L56.3 13.5L55.1 13.5L54.6 11.8L53.1 12.1L52.3 10L49.1 8.2L48.8 7.1L50.6 3L50.2 0.9L49 1.4L48 1.1L48.2 0.5L45.9 0L46 0.8L44.8 1.6L44.3 0.6L43.7 0.8L43.6 2L41.4 2.8L40.8 4.1L39.9 3.3L40.2 2.8L37.7 2.5L36.5 3.3L34.8 2.5L25.9 2.4L24 3L6.6 2.5L6.8 4.1L6 4.4L4.9 7.1L4.9 8.8L6 11.3L7.5 11.8L6.5 12.4L6.3 13.5L7.7 14.3L7.1 15.5L8.2 21.3L7.2 23.2L7.9 23.6L7.5 25.2L8.8 26.3Z', w: 68.7, h: 100, b: [-3.26, -11.24, 1.2, -4.75] },
GL: { d: 'M43.4 0L36.6 1.7L38.8 4.5L37 8.3L32.7 3.4L29.5 4.7L35.6 9L35.7 11.6L33.9 8.3L33.6 10.6L33.3 8.2L29.4 8L33 12.7L30.4 12.4L30.8 16.4L25.2 10.2L23.6 10.4L25.2 17.6L22.3 14.7L20.2 17.6L19.9 11.8L14.3 14.4L17.6 19.6L14.8 15.6L12.5 16.2L12.5 22.1L10.7 20.5L11 23.9L9.9 21.2L6.2 26.5L7.8 28.7L9.8 27L9.1 31.9L0 39.4L4.2 43.2L7.6 42.5L7.7 44.3L4.4 44.1L7.5 45L1.9 45.2L5.7 47.1L3.8 48.3L5.1 49.9L10.7 48.5L16.3 51.6L20.8 64.7L19.1 67.6L21.2 68.5L21.7 65.2L25 72.2L20.5 71.2L25.3 73.2L24.9 78L21.7 78.6L25.2 79.7L21.9 78.9L21.4 80.9L25.3 80L21.4 81.1L24 81.4L21.3 82L23.1 82.8L21.5 84.9L25.2 82.8L21.7 85.1L25.1 86.1L22.7 87L23.3 89.9L24.9 87.3L26 89.6L23.6 90L25.5 89.9L23.7 90.4L25.9 95.5L27.8 98L30.9 97.2L31.6 100L33.8 98L33.2 93.5L36.1 91.2L35.4 87.5L39 84L39.8 85.9L42.5 84.2L44.9 77.6L48.4 78.9L56.5 73L49.4 73.2L51.8 72.1L48.6 72L49.5 70L52.8 69L49.4 66.5L57.2 71.6L56.7 67.4L50.6 63.4L53.2 62.6L50.3 62.3L53.6 60.9L52.5 59.1L58.3 61.1L56.5 56.4L60 56.9L56.1 54L58.3 54.1L56.5 51.8L59.3 54.2L59.6 51.6L55.8 47L60.8 46.4L60.6 43.8L57.6 42.8L59.7 41.6L56.6 41.9L61.6 28.2L58 29.6L63.2 25.3L57.4 25L63.3 23.9L67.6 17.7L64.5 15.1L61.4 15.7L60.6 18.9L58.3 19L58.6 17.1L53.6 25.3L55.6 14L50.2 19.1L52 14.6L43 16L46.2 13.9L44.7 13L52 13.3L56.7 9.3L53.5 6.7L51.2 8.1L52.2 4.4L46 4.8L40.2 8.7L42.4 4.4L51.5 3.3L43.4 0Z', w: 67.6, h: 100, b: [-73.06, -165.51, -12.14, -75.36] },
GM: { d: 'M2 26.2L2.6 25.9L2.3 24.5L3.6 23.8L3.3 22.8L3.6 23.2L4 22.5L33.4 22.5L33.4 16.4L41.1 16.4L42.7 15L44.4 15L46.7 16L50.7 15.7L52.6 14L53.3 10.2L54 10.2L55.3 8.5L57 7.8L57.6 9.5L60.3 11.9L61.9 12.3L62.6 12.9L64.9 12.9L65.6 14L66.6 14.3L67.9 14L68.9 15.7L70.9 16.7L72.8 16.7L73.8 16L75.2 17.7L77.8 18.1L79.1 19.8L80.5 20.4L84.4 20.1L84.8 20.8L88.4 20.8L89.1 20.1L89.1 18.7L92.7 18.4L94.4 17.4L98 17.4L99.7 15.3L100 13.3L96.7 9.2L94.7 8.5L93.4 8.5L92.4 9.5L90.4 9.2L87.7 9.9L86.4 10.6L85.1 12.3L84.8 11.9L81.8 12.6L78.8 11.2L78.8 10.6L77.5 9.9L77.5 8.5L75.8 6.5L72.2 5.8L70.2 7.2L68.5 6.8L67.2 6.1L66.9 3.8L65.2 1.7L63.6 1L60.3 1L57.6 0L53 2.7L51.3 2.4L51 1.7L47.4 2L45 4.8L44.4 8.2L8.6 8.2L9.3 8.9L9.3 10.9L8.6 11.9L10.3 12.3L10.9 13.3L10.9 15L9.9 16L11.6 16L12.9 17L15.6 16.4L16.9 16.7L21.9 12.6L23.5 13.3L26.2 13.3L32.1 12.3L33.4 12.9L35.1 12.6L37.1 11.2L38.7 11.2L39.7 12.3L40.1 11.2L41.1 10.9L40.1 12.9L39.1 12.9L37.7 11.6L37.1 12.6L34.8 13.3L31.1 12.9L30.8 13.6L25.5 14.3L21.9 13.6L19.9 15.7L19.5 17.4L15.6 18.4L14.9 19.4L14.2 19.4L14.2 18.7L11.6 19.1L10.9 18.4L8.6 18.4L7.6 17L7.9 16L7 14L6.3 14L6.6 12.9L7.9 13.3L7.9 12.6L6.3 11.9L5 12.3L4.6 11.6L3 12.6L1.7 15L0.3 15.3L0 16.4L0 18.7L0.7 19.1L1 20.4L0.7 22.8L1.3 23.5L1.3 25.5L2 26.2Z', w: 100, h: 26.2, b: [-16.81, -13.97, -13.79, -13.17] },
GN: { d: 'M95.4 34.8L95.4 32.4L94.3 32.5L93.7 30.9L92 31.3L91.4 29.1L90.6 28.9L91.3 23.1L88.6 22.3L88 23.4L86 23.9L87.5 20.5L88.7 19.2L90.1 19.4L89.5 18.4L90.3 18L88.3 17.4L88 16.4L86.8 16.7L85.6 14.2L83.5 14.2L84.6 9.6L82.7 8.8L83.1 7.1L81.6 6.3L82.2 4.3L78.1 2.2L75.9 2.8L77.4 4.2L76.8 5.9L72.8 6.8L72.1 8.9L63.2 6.1L60.9 7.5L58.6 10.7L57.4 10.2L57.1 7.7L55.3 6.1L53.5 6.3L51.6 9.2L50 9.1L47.4 6.7L48.2 4L49.2 3.9L48.5 3.2L43.9 4L42 3.1L39.5 3.3L35.7 5L35.1 3.9L30.1 3.2L28.5 1.5L27.5 2.8L26.2 2.7L26 0.3L17.3 0L18.4 2.9L18 5L13.9 6.6L17.4 9.1L17.3 13.5L15.4 12.8L15.1 14.1L12.4 14.5L9.7 13.8L6.4 16.3L4.2 16.3L0 23.7L1.2 24.1L1.2 22.8L1.9 23.9L4.2 22.7L2.2 24.2L2.3 25.7L3.5 24.5L2.9 27.3L4.5 25.9L3.8 28.4L6.9 24.8L5 27.3L4.9 28.8L5.6 28.8L4.4 29.3L4.4 30.9L7.1 32.4L7.1 34.2L10.4 34.5L10.6 35.7L11.6 34.3L12.4 35L13.2 34.3L13.1 37.1L15.8 39.3L17.7 37.9L16.9 39.6L18.3 38.9L18.5 40.3L19.5 40.3L17 43.9L19.5 42.8L18.9 43.3L19.9 44L20.4 43L20.3 45.1L21.5 45.3L21.7 44.3L21.1 47.2L24.1 45.7L23.3 46.9L22.5 46.5L22.6 48.7L24.5 47.2L23.6 47.6L23.4 48.6L24.5 48.6L22.9 50.4L24.3 49.5L25.9 50.2L27.7 48.4L27.7 46.9L29.8 46.8L34.5 38.8L37.6 38.1L39 38.9L41.8 37.9L42 37.1L51.4 37.1L56.7 43.7L56.8 45.8L58.9 46.6L57.9 49.8L60.1 50.2L60.2 53.7L61.6 55.3L59.4 57.3L58.3 60.8L58.7 59.9L60.6 60.4L63.5 57.7L66.3 57.4L67 58.8L70.8 57.1L71.7 58.6L72.5 57.8L72.5 59.2L74.9 59.7L74.4 62.1L75.2 62.2L75.2 64.1L75.9 63.9L75.5 66.3L76.7 68.1L74.9 73.4L76.7 72.4L78.7 73.1L80 75.8L82.6 74.3L83.7 74.7L85.8 68.8L87.6 68.8L87.3 69.8L88.7 70.7L89.6 69.8L92.6 70.7L94.1 64.3L95.9 64L95 63.3L95.4 61.9L94.4 62.6L91.8 61.5L91.8 58.2L96 57.7L97 58.9L97.5 57.8L98.5 59.5L100 59.6L99.3 55.7L98 54.1L95.5 53.4L96.5 50.5L98.8 50.1L96.2 48.2L97.1 45.1L94.6 45.4L93.2 43.6L93.9 39L93.1 37.2L95.4 34.8Z', w: 100, h: 75.8, b: [-14.98, -12.77, -7.64, -7.21] },
GQ: { d: 'M49 50.8L48.6 50.8L48.3 49.4L48.3 49.7L46.9 50.1L47.3 55.2L47.6 55.6L47.3 56.9L46.2 58.3L47.6 61.4L47.9 64.5L47.6 64.8L47.3 64.5L47.3 65.5L45.5 66.9L45.5 68.2L44.9 69.3L44.2 69.9L43.5 69.9L42.8 71.3L41.1 72.7L40.8 74.1L42.1 75.8L43.2 76.1L43.8 75.8L44.2 76.1L43.5 76.8L42.8 76.8L42.8 76.5L41.1 75.4L39.7 78.5L38.7 79.2L38.7 79.9L36 81.9L36 84.7L35.3 84.7L34.9 85.7L33.9 86.1L33.6 87.8L33.2 87.8L33.6 88.1L32.9 88.4L32.9 89.5L32.5 89.1L32.5 89.5L31.8 89.5L32.5 90.2L32.5 90.8L33.2 91.2L33.2 91.9L34.2 92.2L34.2 91.9L35.3 91.9L35.3 91.5L36.6 91.2L36.6 90.8L37 91.2L37.7 90.8L37.7 91.2L38.7 91.2L39.7 92.6L40.4 94.6L43.2 92.9L46.9 92.6L48.3 93.2L48.3 93.6L46.9 93.9L47.6 94.6L47.6 95.6L49 95.3L49.7 96L50.3 96L50.7 97L51.4 97.7L52.7 98L52.7 98.4L54.1 98L54.1 96L54.5 95.6L54.1 95.6L100 95.6L100 55.6L54.1 55.6L53.4 54.9L52.4 54.9L52.4 54.5L52.1 54.9L52.1 54.2L51 54.5L51.4 54.5L51 53.8L49.3 53.2L48.6 51.4L49 50.8ZM10.3 0L10.3 0.3L9.6 0.3L9.2 1.7L8.6 2.1L8.2 3.1L6.8 4.1L6.8 7.5L5.5 9.3L5.8 9.6L5.8 11L5.1 11.3L4.5 11L1.7 11L1 11.7L1 12.4L0.7 12.4L0.7 13.4L0.3 13.4L0 14.8L0.3 14.8L0.3 16.1L0.7 16.1L1 17.5L2.1 17.8L2.1 18.2L3.8 18.2L3.8 18.5L4.8 18.9L6.2 18.5L9.2 19.9L10.3 18.5L10.3 17.8L12.3 15.8L12 13.4L13 13.4L13 12.7L13.7 12.4L13.4 12.4L13.7 12.4L13.7 11.3L14.7 10.6L14.4 10.3L14.7 10.3L15.1 8.9L15.4 8.9L15.1 8.9L15.4 8.9L15.4 8.2L16.1 7.9L16.8 6.2L18.2 4.8L17.8 4.8L18.2 4.5L18.2 3.4L17.8 3.4L18.2 3.1L17.8 3.1L17.5 1.4L16.8 0.7L16.8 1L16.8 0.7L16.1 0.7L16.1 1L16.1 0.7L14.7 1L15.1 1L14.7 1.4L14.7 1L14.7 1.4L14.7 1L13.4 1.4L13.4 1L12.7 1L13 0.7L12.7 1L11 1L10.6 0.7L11 0.3L10.3 0Z', w: 100, h: 98.4, b: [8.41, -3.79, 11.33, -0.92] },
GR: { d: 'M0 31.2L8.1 42.1L8.9 40.5L12.8 41L13.4 43.3L8.6 42L8.3 44.5L11.2 46.3L13 51.7L15.4 49.6L17.2 51.8L22 50.1L27.2 51.3L27.5 49.5L29.1 52L30.4 50.4L31.8 52.7L36.9 53.9L32.7 55.5L34.1 56.4L32.8 57.1L21.1 51.1L18.2 54L15.6 52.9L12.5 58.2L18.9 64.3L17.8 68.2L19.4 73.2L21.4 74.5L22 70.7L24.5 70.2L28.3 79.2L30.2 73.2L36.6 78.7L31.1 62.3L35.8 64L36.2 66.5L40.3 64.3L38.9 61.3L38.2 62.9L36.2 61.7L36.4 58.8L34.2 57.8L40.7 55.3L46.2 61.1L46.7 53.3L41.6 50.8L40.7 48.6L38.1 48.8L38.1 46.7L28.9 43.5L35.1 41L32.2 38.3L33.5 36.1L36.8 38.6L35.1 40.2L38.3 38.8L29.6 26.4L29.6 19.3L33.5 16.9L33 21L38 23.5L41.7 23.1L45.6 27.3L45.8 24.9L43.1 23.7L42.7 21.1L47.4 22.2L49.3 24.9L50.4 24L45.9 19.8L44.4 20.5L42.8 15.1L46.9 15.7L50.9 12.2L55.1 13.7L58.8 11.3L70.2 15.4L72.8 12.4L72.4 7.7L75.9 6.3L75.6 2.3L70.5 0L70.2 6.1L61.1 7.8L52.8 5.1L52 2.8L46.8 3.1L43.2 5.4L32 6.3L31.1 9.3L23.5 9L18.2 13.4L11.1 13.7L11.9 18L8.9 20.1L7.7 25L3.4 26.7L4.7 29.1L2.5 31.8L0 31.2ZM42.8 89.2L42.6 91.7L40.9 90.6L40.9 95.7L50.4 96.2L54.1 97.6L54.1 100L70.3 98.9L72.4 96.4L72.3 94.4L66.3 97.3L66.1 94.1L62.2 94.8L56.9 92.8L49.2 94L46.5 92L47.8 90.6L44.2 91.6L42.8 89.2ZM38 40.8L32.3 44.1L36.5 43.8L41.7 50.2L46.3 50.4L48.1 54.9L52.1 56.5L52.5 53.7L48.7 53L47.5 46.4L41.1 44.9L38 40.8ZM72.7 35.8L66.8 38.8L69.7 40.1L71.6 38.3L69.6 40.4L74.8 41.9L73.7 39.9L75.7 40.5L72.7 35.8ZM94.3 78.4L88 82.5L88.8 86.5L92.7 84.1L94.3 78.4Z', w: 94.5, h: 100, b: [20.01, -46.03, 28.25, -37.31] },
GT: { d: 'M72.6 0L29.2 0L29.2 14.1L18.6 14.1L19.1 15.1L20.8 15.6L20.8 16.3L22.6 15.8L22.6 17.6L23.6 17.8L25.2 20.3L26.2 20.3L26.2 21L27.6 22L27.4 22.7L29.2 23.7L29.2 22.7L29.9 22.7L29.5 23.5L30.7 23.7L30.9 24.7L33.7 25.2L33.7 25.9L35.8 26.9L37 28.9L36.8 30.6L37.7 30.4L37.3 32.1L38.2 32.1L37.5 33.1L40.8 33.3L41 34.5L42.7 34.3L42.9 35L43.4 34.5L43.2 35.5L43.9 36L42.9 35.8L43.6 36.5L42.2 37.5L42.7 38.5L41.7 38.7L42.4 39L41.7 39.5L42.4 40.4L42 40.9L42.4 41.9L41.7 41.9L41.7 42.6L42.4 42.2L42.2 43.1L11.8 43.1L9.9 46.1L0.5 63L4 67.6L3.5 68.8L2.4 68.8L1.9 69.6L2.1 72.3L1.2 73L1.2 74.2L2.1 76.9L0.9 80.1L0 80.8L10.1 89.1L21.5 94.9L26.9 95.9L35.1 95.4L39.9 95.9L50.2 100L50 97.6L51.6 96.4L51.9 95.1L55.9 92.2L59 93L58.5 92L60.4 89.1L63.9 88.3L63 85.9L62.3 86.1L62.3 85.2L63 85.2L62.7 83.5L63.4 83.5L63.4 84.4L64.8 83.2L66 83.7L66.7 82.7L67.9 83.5L67.7 82.3L69.1 81.8L70.3 79.6L72.6 79.6L72.4 77.4L73.1 76.4L72.2 75.9L72.2 74.7L70.7 72.5L72.4 70.5L71.9 68.4L77.3 65.7L86.5 58.8L91.7 54.2L92.2 52.7L93.1 52.9L92.9 52.5L94.1 52.2L94.3 51.7L92.7 50.3L85.1 45.6L85.4 46.6L88.2 48.5L87.7 49L86.3 48.3L86.5 49.8L85.4 51.2L85.6 52.2L84.7 52.5L84.9 51L82.5 49.3L81.4 49.8L81.4 50.5L79.9 50.5L78.5 52.7L77.6 52.2L77.3 52.9L76.2 53.2L76.6 56.9L74 59.1L72.6 59.6L70 59.1L69.8 60.3L68.9 60.8L68.1 60.3L68.6 58.8L68.1 58.1L66.3 57.8L67.7 56.6L69.8 56.6L70.5 55.4L72.6 55.6L74.5 54.9L76.2 53.9L76.4 52.7L77.3 52.7L77.8 51.2L79 51.2L79.7 50L81.4 50.3L82.1 49.3L80.4 48L78.8 48.3L78.3 47.3L75.7 47.6L74.7 46.8L74.7 47.3L70.7 47.6L72.6 20.3L72.6 0Z', w: 94.3, h: 100, b: [-92.23, -18.11, -88.23, -13.87] },
GW: { d: 'M97.4 0.7L49.2 0L34.3 8.6L24.6 8.3L21 7L16.5 7.6L11 10.9L0 11.6L1.9 11.9L1.6 10.9L7.8 16.6L12.3 16.6L12.6 14.3L13.6 14.3L12.6 15.6L13.9 15.6L16.5 13.6L15.9 11.9L16.5 13.3L19.1 13.3L19.7 11.6L22 10.9L25.9 12.3L20.4 11.6L12.9 17.2L12 19.5L12.6 22.5L14.6 22.2L16.2 25.5L19.7 26.8L25.6 24.8L23.9 21.2L25.6 23.9L29.8 22.5L25.9 26.5L24.3 30.1L24.9 31.8L28.5 30.5L29.8 26.8L32 27.5L29.8 27.2L29.4 31.5L36.6 28.5L39.8 24.8L42.7 23.9L50.5 25.8L52.8 23.2L55.7 23.2L54.4 28.5L57 31.1L54 29.5L54.7 24.5L53.7 24.2L50.8 26.8L43.4 26.5L37.9 32.1L39.8 30.1L38.2 33.4L40.1 32.4L40.8 34.8L42.4 33.4L40.8 35.8L41.7 38.1L42.4 35.8L42.7 37.1L44.3 36.4L43.4 34.4L45.3 35.8L45 33.1L45.6 35.4L45.6 34.1L46.9 34.8L45.6 32.1L46.6 33.1L47.2 31.5L46.9 33.8L48.5 33.8L48.9 30.1L49.5 31.5L50.8 31.1L47.2 35.1L49.8 33.4L50.5 34.4L51.5 32.4L51.5 33.8L52.4 33.1L51.1 34.8L52.4 35.1L53.1 33.8L52.4 35.4L54.7 34.8L53.7 36.7L56.6 36.4L54.4 37.7L52.4 37.1L51.8 38.4L52.4 37.4L51.5 37.1L53.4 36.7L52.1 35.1L52.1 36.4L51.1 34.8L49.2 35.1L50.8 35.8L49.5 36.4L50.2 37.4L49.2 35.4L46.6 35.4L48.5 37.4L46 35.8L45 36.7L46.6 36.7L46.6 38.4L44.7 37.4L44.7 39.1L43.7 38.1L40.5 40L41.1 41.4L39.5 43.7L39.5 45L40.8 45L46 40L44.7 43.3L47.9 41.4L42.1 45.3L43.4 48.3L42.4 49.3L46.9 52.3L49.2 51.3L48.2 48.6L49.8 48.3L48.9 48.6L49.2 52.9L46.9 54.6L47.6 55.9L49.2 55.9L54.4 49.9L55.3 50.6L56.6 48.3L55.7 47.3L57.3 48.3L57 45L57.6 48L58.9 46.6L58.9 48.3L56.6 48.6L55.7 50.3L57.3 52.3L55.3 50.9L52.4 54.2L52.4 57.9L53.7 58.5L57.3 55.6L66.3 39.4L71.5 39.4L77.7 36.1L79.3 33.4L85.8 35.1L90 34.4L90.6 33.1L92.2 34.1L92.9 31.1L95.1 33.4L97.4 32.8L97.7 22.2L95.5 21.9L94.2 19.5L89.3 16.9L90.6 14.9L92.6 14.9L92.6 13.6L93.9 14.3L94.5 12.9L94.5 14.6L95.8 13.6L97.4 14.6L99 12.6L98.7 9.3L100 7.6L97.4 0.7Z', w: 100, h: 58.5, b: [-16.72, -12.8, -13.63, -10.99] },
GY: { d: 'M66.4 89.6L66 88.4L63.9 88.4L62.1 85.8L62.4 85.1L61.3 84.7L60.5 81.7L59.8 81.9L59.4 79.8L57.8 78.1L58 77.4L57 77.4L56.7 76.3L57.4 75.8L56.7 75.9L56.6 74.8L57.1 74.7L56.1 73.9L56.4 73.1L55.7 73.3L55.6 69.8L53.8 69.8L53.2 70.5L50.7 70.1L50.2 69.5L50.7 67.9L48.4 66.1L48.3 64.7L46.9 62.6L45.7 62.1L45.2 59.9L48.4 52.9L47.1 50.5L47.9 49.3L49.4 49L50.5 47.8L55.7 47.8L55.6 45.5L56.3 45.4L56.6 46.2L57.2 45.8L56.7 44.4L56 44.3L55.9 45L55.7 43.6L55.1 43.5L57.5 39.5L57.5 33.1L55.3 30.7L53.2 29.7L52.5 31.8L51.1 32.1L52.5 31.7L51.3 28.1L45.2 23.1L43.8 23.2L43.2 25.8L43.7 23.2L41.9 22.3L39.9 22.8L38.6 24.8L38.3 30L37.7 28.7L36.8 29.2L37.7 28.1L37.9 23.5L39.5 20.8L39.5 15.8L30.6 6.3L21.6 1.5L23.5 3L22.2 2.3L22.3 3.1L19.3 0L21.4 2.5L21.5 3.7L19.3 4.5L18.7 6.7L17.5 6.7L14.2 9.6L11.8 9.6L11.1 12.2L9.3 13.4L11.1 16L10.7 18L11.6 19.1L12.7 18L14.2 18.3L15.2 19L14.9 20.3L13.9 21.7L12.9 21.6L9.5 24L6.6 23.2L6.4 24.6L3.5 24.3L2.4 26.6L3.7 28.5L3.1 29.9L4.1 30.7L3.9 31.8L3.1 31.8L0.8 34.8L0 34.8L9.1 45.2L10.4 44.8L13.1 45.5L16.1 44.3L17.5 45L19.5 47.3L18.8 51.9L18.1 53.3L16.9 53.8L16.9 54.6L21.9 55.2L23.5 56.4L22.7 59.1L24.2 59.6L23.8 60.6L25.7 62.3L24.6 63L24.6 64.2L23.5 64.5L23.7 65.5L20.8 67.4L21.8 68.2L21.2 69L21.8 70.2L20.3 72.2L19.2 76.3L20.3 83.5L22.9 84.8L22.5 90.7L23.7 90.5L23.3 92L25.4 92.4L29 96.9L33.7 98L34.9 100L36.4 99.2L36.5 98.2L39.4 98.6L39.8 97.3L39.2 96.2L40.7 95.9L41.8 94L44.1 94.7L44.2 95.4L45.9 95.4L46.3 93.4L47.2 93.6L49.1 92.6L52.5 92.7L53.7 90.1L55.3 88.9L56.6 89.4L58.4 88.5L59.3 89.7L60.7 89.6L62.4 90.7L63.3 89.7L66.4 89.6Z', w: 66.6, h: 100, b: [-61.41, -8.55, -56.49, -1.16] },
HN: { d: 'M24.8 43.6L26.2 44.6L27.9 42.6L28 44.3L28.5 44.1L27.4 44.3L27.4 45L28.5 45.4L28.3 44.5L30.3 44.6L31.4 43.5L31.9 44L30.6 44.5L30.8 45.6L29.6 45.8L30.8 46.6L31.1 48.8L32.7 48.6L32 49.3L32.9 50.1L33.5 49.7L33 50.6L37.7 50.6L39.3 49.1L39.5 45.9L40.6 45.3L42 45.9L42.8 45.3L41.7 41L42 39.8L41.4 39.5L41.9 37.7L45.6 37.2L48.5 37.8L51 34L51.9 34L51.5 33.3L53.5 32.5L54.1 32.7L53.5 34L55.7 35.5L56.7 35.2L56.8 36.2L58.1 36.7L58.1 34.3L63.8 31.9L64.7 29.7L67.3 29.7L67.8 28.9L66.7 27.5L67.8 25.9L67.8 24.2L69.6 24.4L69.4 22.7L71.8 20.2L73.6 20.2L75 22.9L75.5 22.4L78.9 23.5L80.4 21.9L82 22.9L82.4 21.2L83.6 22L84.7 21.7L84.4 20.7L86 21.5L87.6 21.4L87.6 20.7L88.9 21L89.7 20.7L89.2 20.4L90.7 20.2L90.5 19.7L91.1 20.2L90.8 19.5L92.4 19.5L94.2 17L94.8 17.5L95.8 16.7L98.1 17L97.7 17.5L100 17.2L97.3 15.5L96 12.9L90.8 10.5L90.5 11.4L93.6 12.5L92.1 12.2L90 14L90 12.7L89 12.7L89.5 12.5L89 11.9L86.6 10.5L85.5 10.7L85.8 11.9L84.7 11.5L84.1 10.2L85.5 8.4L90.2 11L90.7 10.5L88.1 9.5L81.3 3.5L75.8 2.7L78.4 3.5L77.5 4.4L76.3 3.5L75.8 4.4L75.7 2.7L69.9 0.7L66.7 2.3L62.3 2.8L58.8 1L53.9 0L55.6 0.7L54.9 1.8L50.7 2.5L48.3 4.2L45.9 3.5L38.3 4.5L32 3L29.6 4.2L27.9 2.3L28.3 1.7L27.7 2.5L26.2 1.8L22.7 2.7L23 3.3L21.1 4L20 5.7L17.9 5.2L17.7 5.9L16.7 5.9L16.3 7L11 11.5L2.9 16.5L3.2 18L2.1 19.4L3.7 22L3.4 24.2L1.8 24.2L0 26.9L3.4 28L4.3 27.2L5.3 29.4L8.1 30.9L8.5 32.2L10.1 31.9L10 32.9L11 33.7L13.7 34.2L14.3 36.3L17.6 35.3L18.4 34.7L18 33.8L20.8 34L21.7 36L25.1 35.2L26.6 36.8L25.3 41.1L25.4 42L26.4 42L26.4 43.1L24.8 43.6Z', w: 100, h: 50.7, b: [-89.36, -16.24, -83.15, -13.09] },
HR: { d: 'M92.8 40.9L92.4 39.4L95.1 38.7L93.3 38L94 34L95 34.4L95.3 32.8L97.5 33.5L97.8 32.5L98.3 33.7L100 32.8L91.9 28.7L93 28L92.3 25.9L94.1 25.1L90.8 23.9L92.1 22L90.9 20.8L92.1 20.8L90.9 20.6L91.9 19.1L89.9 19.1L91.1 18.9L90.8 17.7L89.8 18.2L90.9 15.5L89.3 16.5L86.7 15.5L83.2 19.9L75.7 18.4L73.2 19.1L70.1 17.7L68.5 15L64.6 15L65.4 14.3L63.3 13.4L61.6 9.5L58.4 8.3L53.4 2.4L50.8 2.2L48.3 0L46.1 1.5L47.1 4.4L43 4.1L42.4 6.1L36.1 8.5L35.4 10.9L37.6 12.4L37.2 17.2L34.4 17.2L29.7 20.1L32.2 22L30 23L31.9 25.9L30.9 26.8L28 27.5L24.3 24.9L22.3 26.6L20 24.9L18.8 21.5L16.9 23L16.9 24.7L14.1 26.3L8.1 25.1L8.2 26.8L5.4 27.3L0 25.9L0.7 29.7L2 30.2L1.7 34.2L3.9 34.4L1.8 34.7L2.2 36.1L7.2 43.2L6.5 41.8L8.7 42L9.4 36.6L9.6 38.7L11.4 38.2L11.4 34.4L14.1 29L18 30.4L19 32.5L22.8 34.9L24.7 47L30 52.6L34.4 55L35.9 58L33.2 56.4L34.1 55.2L30 53.3L30.4 55.2L27.2 54.7L27.7 56.6L36.9 66.6L39.8 67.6L39.1 65.9L40.1 68.3L41.4 68.3L40.4 69.2L41.6 69.7L40.6 69.7L40.8 71.5L41.9 71.7L41.3 72.7L44.3 73.4L45.1 72.7L44.1 72.2L49.3 71.5L50.2 72L48.8 72.7L57 75L66.1 84L67.3 83.5L67.8 85.6L70.8 84.6L69.5 81.9L64.4 78L63.4 73.1L59.1 71.1L50.5 60.4L45 56.2L45.8 52.9L44.3 52.2L44.1 48.9L42.4 47.9L43.1 46.5L37.8 42.3L38.1 33.5L39.4 32.1L41.9 32.1L45.3 36.8L48 37.5L50.5 32.3L55.9 33.3L57.9 30.9L57.9 32.1L61.6 34L63.6 33L66.9 34.9L70.3 34.4L73 36.6L75.5 34L77.5 35.6L79.2 35.6L79.5 34.2L82.9 34.9L84.6 36.6L84.6 35.4L87.1 35.4L87.9 37.8L89.1 37.8L88.4 39.9L92.8 40.9ZM69.8 86.7L71.3 88.1L63.8 84L58.9 83L59.1 84.2L62.6 84.6L71.5 89.7L70.6 88.1L73.8 89L84.6 98.1L81.9 92.9L79.7 93.3L73.8 88.5L73 86.3L69.8 86.7Z', w: 100, h: 98.1, b: [13.49, -52.74, 19.45, -46.89] },
HT: { d: 'M95.1 9.3L92.3 8.5L93 11.1L92.3 10L90.2 10.4L92 8.5L88.1 7.8L87.1 9.7L84.6 9.7L85.3 8.2L83.9 9.3L84.6 8.2L79.7 7.8L79.7 5.9L75.5 6.7L75.2 8.9L73.1 4.8L69.2 4.8L66.4 1.9L59.8 0L53.1 1.5L45.8 0.7L42 3.3L37.1 4.1L39.2 4.5L35.7 7.1L35.7 10L37.8 11.9L43.7 11.9L44.1 10.8L46.5 12.3L49 11.5L57 15.6L60.1 18.9L62.2 18.2L62.6 20.4L61.5 19.3L61.5 21.2L59.4 22.3L61.5 24.9L59.4 24.9L59.1 26.7L60.1 26.7L58.7 27.1L62.2 30.8L57.7 33.4L64.7 38.9L67.5 43.7L69.6 43.7L71.3 45.9L75.5 47.4L74.5 52.6L65.4 51.5L61.2 56.6L55.2 56.6L55.2 55.5L52.1 54.8L48.3 55.9L45.8 54.1L43 54.8L35.7 52.6L28.3 53.7L28.7 52.2L26.9 52.2L27.6 51.5L31.5 51.9L31.8 50.4L24.1 52.2L14 49.6L11.2 47.4L7.3 47.4L1.7 49.6L0 55.9L1 59.6L3.8 61.4L8.7 60.7L13.3 62.9L18.5 67.3L20.6 71.4L24.1 71L22 67.7L27.6 63.3L29 62.9L28.3 64.4L29.4 64.4L29.4 62.9L30.4 62.9L30.1 64.4L31.8 64.4L31.8 62.5L34.3 63.6L38.1 61.8L38.8 64.4L42 63.6L56.3 67L60.5 66.6L60.1 65.5L67.8 65.9L67.5 63.6L83.9 63.3L89.2 65.1L95.1 71L95.8 67L94.1 65.1L97.2 59.6L90.2 55.2L90.9 53.7L86.4 49.3L87.8 47.8L93.4 48.9L93.4 46.7L95.8 45.6L96.5 39.7L94.4 36.7L90.6 36.7L96.5 33.4L100 27.8L94.1 23L97.6 21.9L97.9 19.3L95.1 9.3ZM44.8 36.3L40.9 38.2L43 40.8L49 42.6L50.7 44.5L58.4 46.3L58.4 43L56.6 41.1L44.8 36.3Z', w: 100, h: 71.4, b: [-74.48, -20.37, -71.62, -18.32] },
HU: { d: 'M0 37.7L3.4 38.1L3.1 40.2L4.7 41.5L4.1 42.8L5.9 44.1L6.2 45.6L8.2 46.5L9 48L10.6 48.2L12.7 51.6L15.5 52.6L15.8 53.9L16.5 54.1L16.1 54.6L17.5 55.6L16.9 56L17.7 56.9L18 56.3L18.9 56.9L18.1 57.5L21.5 57.5L23 59.9L25.3 60.3L25.6 61.1L27.8 60.5L29 61.3L30.8 60.7L34.5 61.8L34.5 61.1L35.9 60.7L37.1 58.8L37.6 59L37.6 58L39.3 58.8L40.9 57.7L42.7 58L42.6 57.1L43.7 57.1L44 55.4L45.1 56.5L47 56.5L46.7 55.8L48.6 55.4L50.8 52.4L52.9 52.2L54.3 53.5L56.3 52.4L57.6 52.4L58.2 53.3L58.9 52.4L59.4 53.3L60.1 52.9L61.1 53.7L62.6 52.6L64.1 53.3L64.8 52.2L66.9 53.5L68.5 52L68.8 50.3L72.5 50.5L73 51.2L73.8 49.9L74.7 49.9L75 48L76.4 47.5L76.7 46.7L75.8 45.6L76.6 43L78.1 43L78.8 42L78.4 41.3L79.4 41.5L79.8 40.9L79.1 40L79.7 38.3L80.9 37.9L80.9 36.4L82 35.7L81.6 34L83.7 32.5L86 26.6L87 26.6L87 23.6L88.7 21.8L89.4 22L91.5 18.1L93.1 18.8L93.7 17.2L95.7 18.1L98.2 16.6L97.9 15.5L100 14.2L99.1 13.5L99.7 12L99 10.4L95.6 10.9L95.1 8.7L94.1 7.5L92.3 8L91.5 5.3L90.3 4.7L90.7 4L89.8 3.6L88.1 4.9L84.5 4.9L84.2 5.8L82.6 5.3L81 1.8L80 1.8L78.5 0L78.2 0.7L76.7 0.7L76.6 1.6L75.3 1.1L73.9 2.2L73 1.3L70 0.9L69.8 0.2L64.8 1.1L62.2 7.1L59.4 7.3L58.5 9.1L56.8 9.3L55.8 10.4L55.8 9.3L54.2 9.8L54.5 8.9L53.6 8.2L52.7 8.7L51.7 7.5L50.4 8.4L49.5 11.3L48.9 10.9L46.8 11.1L46.1 12L42.9 11.3L42.3 12L40.1 12L39.9 13.3L38.9 13.7L39 15.9L40.5 17L38.9 17L37.7 18.3L34.5 18.1L32.5 19L29.2 18.3L23.6 18.6L22.2 17L21.5 17.2L20.9 15.9L19.7 15.7L18.4 13.5L15.8 12.6L14.4 13.7L14.4 15.9L13.3 15.9L14.1 17L14.4 19.4L11.2 19.9L11 19.2L10.6 20.1L9.4 20.1L9 18.8L7.1 18.3L4.6 20.3L6.6 21.4L7.8 21.2L8.8 23.1L8 24L8.1 25.1L5.9 26.4L5 26L4.7 27.3L5.6 28.8L4.7 29.5L4.6 30.8L5 31.8L6.2 32.1L5.2 32.7L6 33.8L4.9 34.2L5.9 34.9L2.5 34.6L2.5 35.7L1.2 36.2L0 37.7Z', w: 100, h: 61.8, b: [16.11, -55.75, 22.9, -51.55] },
ID: { d: 'M48.9 3.3L45.1 2.8L42.3 9.2L37.7 8.9L33.6 10.5L30.9 8.1L29.9 11.7L31 15.1L32.5 15.3L32.9 18.8L36 18.5L36.4 20.2L38.9 18.8L41.3 19.4L42.5 21.5L45.3 20.3L46.7 17.3L45.8 16.3L48.9 14.1L49.5 9.9L52 10.2L49.4 8.3L50 7.3L47.6 4.5L49.4 4.3L48.9 3.3ZM0.5 0L0.8 2.3L7.9 8.6L8.6 11.8L13.9 19.4L20.5 25.4L22 24.3L23 25.3L23.7 19L22.8 17.6L20.8 18L20.1 14.6L17.7 13.9L18.7 11.8L16.4 11.9L16.8 10.8L6.5 3.5L5 0.9L0.5 0ZM100 18L93 15.6L87.6 19.8L85.1 17.5L84.6 13.9L80.9 13.2L78 15.5L80.3 15.6L81 17.4L84.6 16.9L84 18.3L80.2 18.4L82.2 19.6L82.3 21.3L84.3 18.7L84.6 20.8L93.4 23.9L95.4 27.3L94.6 27.6L96.1 28L94.9 28.1L95.5 30.4L97.9 29.9L100 32.4L100 18ZM65 8.6L62.8 10.5L56.1 9.4L54.2 10.8L53.9 14.2L51.4 18.4L53.3 21L52.9 24.6L55.2 24.7L54.5 18.8L55.8 18.1L57.5 22.8L60.4 22L56.9 16.3L61.7 14.1L55.6 15.4L54.5 11.9L63.9 11.4L65 8.6ZM23.7 25.2L21.9 27.2L24.8 27.6L24.5 28.5L42.3 31.6L42 29.4L38.5 29L37.9 27.3L34.3 26.4L33.2 27.6L29.6 27.3L28.6 26L23.7 25.2ZM71.5 7.6L70.3 10.1L72.6 14.4L71.3 11.7L73.6 11.9L72 10.7L73.2 9L70.8 10.5L71.5 7.6ZM75 18.5L71.9 18.6L71.4 20.2L72 19.1L77.8 20.8L77.2 19.2L75 18.5ZM49.6 30.1L50.4 31.3L47.9 30.7L47 32.1L52.3 31.5L49.6 30.1ZM60.4 30L59.5 31.2L55.1 30.4L53.7 31.6L58.1 31.8L60.3 31.2L60.4 30ZM65.2 33.1L65.3 32L62.8 32.8L61.7 35.1L65.2 33.1ZM94.9 28.5L92.6 30.8L95.5 30L94.9 28.5ZM23.3 15.6L21.7 16.9L25.2 19.1L23.3 15.6ZM54 32.7L51.8 33.3L55.7 34.7L54 32.7ZM68.8 19.1L67.3 19.9L69.9 20.4L68.8 19.1Z', w: 100, h: 35.1, b: [95.19, -5.67, 141.02, 10.43] },
IE: { d: 'M48.7 8.2L53.8 3.7L47.5 0L48.8 2.7L44.6 2.4L45.6 8.5L43.1 11.1L44.7 6.9L43.1 2.7L40.5 4.2L41.9 5.6L41.9 4L42.2 7.4L40.7 3.2L39.8 6.6L37.6 4L35.4 6.9L33.1 5.8L33.3 9L31.4 9L31.9 11.4L30.4 11.6L32.6 14.3L29.9 14L28.9 14.8L31.1 16.1L29 15.8L30.8 16.6L27.4 16.1L25.2 17.9L28.4 20.6L31 19.2L30.4 21.6L35.7 19.2L33.3 22.6L34.5 23.2L30.4 23.9L27.1 27.1L29.8 27.6L28.7 28.4L30.2 29.1L28 29.4L29.8 30.7L21.5 28.6L20.1 32.5L17.1 27.6L10 27.3L9.2 28.4L11.2 29.6L9.5 28.9L9.1 30.7L7.4 30.4L8.9 29.4L7.4 28.4L5.9 29.1L5.3 33.8L7 30.4L8.6 33L7.6 34.5L9.8 33.3L8.6 35.6L10.3 38.4L8.6 37.1L8 39.4L13.6 38.7L12.8 41L14.1 41.2L8.8 42L8.5 45.1L12.2 46.1L4.4 47.3L7 49.6L4.4 51.2L7.9 52.2L10.3 50.9L8.6 53.4L10.6 54.2L14.1 51.9L13 55.7L13.9 54.2L14.4 56L21.9 54.5L23.4 54.7L21.8 56.2L24.2 56.2L23.6 58L18 58L15.1 63L17.2 63L14.8 67.8L8.2 72.5L11.8 72L13.5 69.8L15.9 71.5L20.1 71L23 66.8L22.8 69.5L26.9 69.8L12.8 72L12.8 74.7L8 76L9.8 77L9.1 79.7L11.5 79.9L7.9 80.7L6.3 78.7L5.7 80.4L4.2 79.2L0 81.9L0.3 84.1L10.9 82.4L1.2 89L2 91.7L4.4 89.8L5 92.7L13.9 89.3L4.8 95.9L15.6 92.9L9.5 97.3L14.2 95.6L9.8 99.8L15.4 97.1L16 98.8L17.5 97.6L17.4 99.3L20.3 97.1L23.7 97.6L24 95.6L26.8 96.8L25.8 95.1L27.2 94.7L29.5 95.9L29.3 93.7L32.2 93.4L33.3 91.5L30.5 88.8L34.9 89.8L33.6 91.5L36.9 91L39.5 89.3L39.1 86.6L39.9 87.8L43.7 86.6L42.9 83.6L52.3 83.1L53.1 80.4L53.8 83.4L56.1 79.9L55.8 81.4L62.4 81.9L62.1 78L61.2 79.4L60 77.5L62.3 77.7L67.9 62.3L66.4 54.7L64.2 52.9L67 52.4L64.7 49.9L66.5 47.3L64.1 43.3L62.6 43.5L64.4 41.2L61.7 35.8L65.6 36.6L65.8 35.1L63.6 33.3L58.2 35.1L58.2 32L55.3 30.4L52.4 25.2L49.7 27.3L50.3 30.4L48.4 33L48.4 31.7L47.8 33.3L46.4 32L44.3 33L42.3 30.7L39.8 30.7L34.8 23.9L42.2 20.3L38.7 17.9L44.4 16.9L46.6 9.8L48.7 8.2Z', w: 67.9, h: 100, b: [-10.48, -66.8, -5.99, -60.18] },
IL: { d: 'M14.1 100L15.3 99L15.3 98.5L15.9 98.7L15.9 97.4L16.8 96.1L16.8 93.3L18.2 90.2L18.2 87.8L19.1 86.8L18.6 86.5L18.9 85.8L19.8 85.3L19.8 78.8L20.7 78L20 76.4L20 75.1L20.7 74.3L20.9 71.7L22.5 69.4L22.2 69.1L22.9 68.3L22.9 67L23.4 67L23.6 66L24 66L23.8 64.1L24.3 63.1L25.8 62.1L25.8 59.7L26.5 58.6L26.7 57.1L25.4 54.7L25.4 53.4L26.9 50.8L27.2 48.9L27.2 47.9L25.2 47.9L21.6 51L16.4 51.3L15 51.8L14.6 51.8L13.9 51L13.7 50L15 48.1L15.3 45L16.6 43.4L17.3 43.4L18 42.6L18.4 42.6L18.9 41.8L19.1 42.1L19.1 41.5L20.4 41L21.1 41L21.3 41.5L21.6 41.3L21.3 39.4L19.5 39.4L19.1 38.9L18.6 39.2L18 38.4L17.5 38.4L17.3 39.2L15.5 38.9L15.5 38.1L17.1 37.8L16.8 36.5L16.2 36.3L15.9 35.5L16.6 33.9L15.9 32L16.2 30.4L15.7 30.4L15.9 30.2L15.5 29.9L15.5 29.1L16.6 28.6L16.6 28L16.8 28.3L17.1 27.2L16.6 27L16.6 26.2L16.8 25.4L17.5 24.9L17.3 24.1L18 22.2L18.4 21.7L19.3 21.7L21.3 19.8L22.7 20.9L23.8 20.9L23.8 20.6L25.2 20.9L25.8 21.9L25.8 23.5L27.4 23.5L28.1 24.1L29 24.1L28.7 23L29 23.3L29 21.9L29.4 21.7L29.4 21.1L28.7 20.9L29.2 20.6L29 19.8L29 20.1L29.4 19.8L29 17.1L29.2 17.4L30.1 17.1L29.9 16.6L30.5 16.1L30.1 16.1L29.9 15.5L30.5 15L30.5 14.5L31.2 14.5L31.2 12.9L30.8 12.9L31 11.5L30.5 10.7L30.1 10.7L30.5 8.3L29.6 7.2L30.3 6.4L30.3 6.7L30.8 6.4L30.8 5.4L31 5.6L31.4 3.8L31.4 1.1L30.3 1.3L29.4 0.5L29.4 0L29 0L28.3 4.6L27.6 4.6L27.6 5.4L26.5 5.4L25.8 6.2L24 6.2L23.6 5.6L23.6 4.8L22 5.4L21.6 5.1L18.6 5.4L18.6 7L17.7 9.9L18.2 9.9L17.7 12.1L17.1 12.9L16.2 12.6L15.9 12.1L15.3 12.3L15.3 15L14.6 16.1L14.8 16.9L14.4 17.9L14.6 18.5L14.1 20.9L11.2 32.3L8.8 38.6L7.2 41.8L4.9 45L6.7 46.8L4.9 47.9L2 51L2.2 53.1L0 55L2.9 64.4L5.2 69.1L5.6 73L6.5 74.1L6.1 76.2L7.6 77.2L8.1 78.2L13 93L13.7 95.9L13.5 97.7L14.1 100Z', w: 31.4, h: 100, b: [34.27, -35.34, 35.67, -30.89] },
IN: { d: 'M69.3 32.1L69.1 29.5L66.7 29.7L66.6 35.4L64 35.7L63.1 34.8L59.1 34.9L58.3 33.8L56.9 34.2L53.4 31.4L50.5 32.1L43.9 29.1L39.7 26.4L40.7 22.9L43 20.9L38.5 18.1L36.5 18.1L35.3 16.6L35.4 14.3L34.1 12L35.3 11.3L36.1 12.8L37.2 11.2L35 6L36.3 4.8L33.7 3.6L33.2 0.1L25.5 4.1L20.8 2.9L19.2 3.5L18.6 4.8L20.4 6.3L19.3 7.2L20.1 8.2L19.5 9.3L21.2 11.2L21.8 10.8L21.7 12.2L24.1 13.2L21.5 14.5L21.8 17.8L19.2 20L19.2 21.3L17.4 22.1L16 25.6L13.8 27L12.5 29.7L8.8 30.6L7.3 29.5L4.7 32.6L4.5 34.3L6.7 35L6.4 37.3L8.4 38.2L9.9 43L6.2 43.8L2.1 43.3L1.9 44.5L0.6 44.5L0 45.8L2.1 44.8L0.8 46.2L3.4 48.7L7.6 48.2L6.7 49.7L2.6 50.7L8.9 56.4L13.2 54.6L13.9 50.6L15.8 50.7L14.5 51.6L15.3 51.7L14.6 52.9L15.9 52.8L14.8 54.2L15.9 56.1L15 59.5L15.5 62.7L16.1 61.7L16.4 62.3L15.7 63.6L17.7 72.7L21.2 79L23.4 86.5L25.7 89.6L28 97.2L30.6 99.8L33.1 99L34.1 96.6L36.8 95.9L35.8 95.2L37.2 92.6L39.1 92.5L38.7 87.7L40.7 82.3L39.7 76.3L40.4 74L41.8 73.3L42.7 73.9L43.8 71.9L47.2 71L47.3 69.1L53.3 64.8L56.4 61L60.8 59L63.1 56.3L62.6 54L65.6 52.8L66.9 51.3L66.3 50.9L67.1 53.3L68.4 51.4L68.7 52.8L69.9 53L69.6 47.3L68.1 45.7L68.7 43.4L66.3 42L67.7 40L69.6 39.8L66.6 37.8L68 35.7L67.6 34.7L68.5 36.1L69.9 35.6L70.7 37L71.9 36.2L72.4 39.7L81 40.6L80.2 42.9L77.5 44L76.8 45.9L78.4 48.3L79.4 45.4L80.5 45.4L81.6 51.7L82 51.1L82.7 51.9L83.6 50.7L83.4 47.8L84.3 47.6L84.1 44.1L86.8 45L88.8 40.7L88.4 39.3L90.3 36.8L90.1 34.7L95 31.9L96.8 33L95.9 31L97.7 29.5L95.4 27.2L94.3 27.6L95.1 26.9L93.4 24.4L90.5 25.5L88.6 24.5L83.8 27.5L81.1 30.3L78.4 30.5L78.4 31.5L80 32.3L79.9 33.9L71.7 34.4L69.2 33.5L69.3 32.1Z', w: 97.7, h: 100, b: [68.17, -38.03, 97.4, -8.1] },
IQ: { d: 'M4.1 64L16.4 66.3L26.6 73.2L33.1 76.2L59.5 98.4L76.7 100L79 98.5L81.1 95.6L83.8 89.3L85.7 88.1L89.7 88L91.9 89L92.1 88.3L92.3 89.2L94.5 88.7L96.7 90.1L98.6 90.1L97 89.2L96.3 87.8L96.7 87L95.2 85.2L94.5 85.4L94 84L92.7 83.6L92.8 77.5L89.2 77.5L89.2 72.9L91.1 68.3L89.5 65.5L87.7 64.1L88.1 63L87.4 62.5L87.6 62.1L87 62.1L87.1 61.1L86.1 60L83.4 59.9L79.6 56.3L76.5 54.4L73.3 54.1L74 53L72.8 52.7L72.9 52.3L73.6 52.5L74.6 51.4L74.2 51.4L74.3 50.6L73.2 49.6L72.5 47.7L71 47.9L72 47.2L71.5 46.1L69.9 46.7L70.1 46.1L68.8 45.1L67.6 42.5L66.5 42.2L67.2 41.5L67.1 40.8L68.2 40L68.3 38.1L67.9 37.5L67.4 37.7L66.8 36.4L67 36L67.6 36.1L67.7 34.5L68.3 35.1L69.8 35.2L69.4 33.3L68.9 33L69.3 31.8L70.1 31.7L70.3 30.7L71.1 30.8L71.6 28.4L73.1 29.1L73.2 28.5L74.3 28.3L73.9 27.6L74.4 27L73.5 26.8L73.9 25.8L72.3 23.7L72.5 21.2L75.3 20.6L75.8 19.5L75.3 19.3L74.5 19.9L73 19L70 19.8L68 17.3L65.9 17.6L66.2 16.2L65.6 15.4L65.8 14.7L65 14.2L65 12.3L63.1 12.1L62.3 10.7L63 9.6L62.8 8.3L61.7 7.4L60.7 7.5L61.4 6.3L61.3 4.4L60.1 3.9L60 2.6L58.5 2.4L57 3.9L55.8 4.2L55.4 5.3L54.8 5L54.1 3.4L55.1 2.8L55 1.6L53.7 0.8L52.5 0.6L50.6 2.4L50.4 1.9L47.3 1.8L45.9 0.6L45.3 1L43.8 0.1L41.8 0.9L40.1 0L37.9 3L35.8 3.3L35.9 3.9L30.6 9.8L26.2 10.8L25 12.9L24.8 16.5L25.9 19.3L26 21.9L24.9 23.7L24.4 26.4L24.5 32.3L22.4 36.4L18.6 37.9L0 49.4L3 59.8L2 60L2.5 62.2L4.7 61.5L5.1 63L4.1 64Z', w: 98.6, h: 100, b: [38.79, -40.35, 48.61, -30.39] },
IR: { d: 'M89.3 27.3L88.5 25.1L89.1 24.1L88.7 20.7L84.5 20.8L82.8 18.1L80.4 17.5L79.4 14.9L76.7 13.8L74.4 14.3L73.3 13.2L69.5 12.2L68.3 10L67.6 10.7L64.1 10.1L63.8 11.3L59.3 11.2L57.5 12.2L55.9 13.6L55.2 15.5L51.1 16.1L51.7 19.7L49.5 19.5L51.8 18.7L40.7 21.2L34.7 19.1L32.7 17.6L32 15.8L28.1 15.3L26.1 14.1L25.1 8.9L23.8 9.2L20.7 6.3L22.3 5.1L21.2 3.4L22.5 2.7L20.5 0.5L17.4 2.1L13 6.1L11.2 6.3L7.4 5.3L3.1 0L2.3 0.6L2 2.5L0 2.7L1.5 6.4L1.4 9.3L2.5 9.5L1 12.6L3.1 13.6L2.8 15.5L4 16.4L3.7 17.7L4.6 18.3L4.2 19.8L5.3 20.2L5.4 22.1L6.4 22.3L6.8 25L7.9 24.8L9 26.1L12 26.1L10.3 26.9L11.2 30.5L9.8 30.6L9.5 31.8L8.6 32.3L8.9 34.1L7.8 33.7L7.3 34.7L8 36.5L7.2 37.7L8.9 40L9.7 39.7L9.5 40.7L10.3 40.5L11.2 42L10.7 43.8L12.3 44L15.9 46.8L17.8 47.4L18.1 49L19.9 51.2L18.9 53.6L18.9 55.9L20.7 55.9L20.7 59L22.7 60.8L22.9 61.9L25.4 61.7L25.4 60.5L24.7 59.9L26 59.3L25.2 58.9L26.1 58.9L26.1 59.4L26.9 58.9L26.9 59.9L25.4 59.7L27 60.4L27 61.1L28.3 61L28.6 61.9L31.3 60.7L31.7 62.3L34.3 65.1L34.4 67.1L35.8 67.5L35.1 68.4L36.2 68.9L38.2 74.2L41.6 74.7L43.5 75.8L44.9 77.9L48.9 79.7L50.3 81.4L53.2 81.2L55.8 82.5L58.1 80.9L59.5 81L60.2 79.7L65.5 78.5L67 79.5L68.8 86.7L71 86.8L71.1 87.5L72.1 87.1L73.1 88L75.3 87.5L78 88.9L79.4 88.2L85.2 89.5L84.8 89L85.5 88.6L85.9 89.5L90.2 90.8L90.8 89.8L91.1 90.1L91.5 87.2L92.3 86.9L92.5 83.9L94.2 83.8L95.4 82.1L99.3 81.6L100 78.9L99.3 78.1L97.1 78.1L97.2 72.1L96.2 72.3L92.1 70L89.8 65.6L87.3 62.8L92.1 56.9L92.3 55.7L91.6 53.6L87.2 52.9L87.3 48.6L85.8 42.9L87.7 40.5L85.6 39.8L85.4 36.8L86.3 35.5L87.5 35.6L86.6 34.2L87.9 33.5L89.3 27.3Z', w: 100, h: 90.8, b: [44.03, -43.42, 63.33, -25.9] },
IS: { d: 'M75.5 0.2L72.1 1.1L72 10.4L66.8 7.5L64.5 12.4L62.3 12.4L56.7 8.1L58.8 19.9L52 7.7L49.5 8.6L49.8 11L46.1 11L46.5 18.4L40.1 9.5L37.2 10.1L38.4 19.7L35.6 21L36.7 23.4L35.1 19L32.6 21.2L31.4 30.6L30.3 24.5L27.7 24.5L29.3 21L27.6 21.9L24.7 17.3L29 17.9L29.3 13.5L26.4 13L29.1 11.9L27.1 10.1L25.9 11.7L19.3 1.8L12.8 2.3L13.6 5.7L17.5 3.9L16.5 5.4L19.7 6.1L14 7L19.1 10.1L19 15.9L17.7 17L18.1 12.6L16.9 16.1L16.8 11L13.9 14.2L15.1 11.3L13.1 13L13.9 10.1L9.5 7.7L10.5 10.4L7.9 9.7L10.6 12.6L6.5 11.9L12.2 15.7L6 14.6L8.3 17.5L12.1 16.8L9.1 18.4L11.5 19.2L8.8 20.8L3.9 16.4L6.9 22.5L1.9 20.1L0 23.2L6.9 25.1L15.2 20.3L16.5 23.2L15.9 20.3L17.3 22.7L18.1 20.1L18.5 23L22.1 21L20.5 24.7L21.8 22.5L25.8 24.1L17.5 29.9L22.9 31.6L25.3 29.5L24.8 33.4L19.9 32.5L17.6 34.6L16.3 32.3L13.7 35.7L12 33.8L11.8 35.7L4.3 36.4L6.4 39.8L19.2 37.8L20.8 45.2L25.5 42.5L22 48.6L28.7 46.9L25.2 48L23.6 50.3L25.6 51.3L22.6 51.7L22.6 54.2L18.8 55.7L16.7 53.2L16.3 59L31.5 56.3L30.3 57.7L33.8 59.8L34.7 57.1L35.5 60L33.8 59.8L39.2 64.5L52.8 67.5L57.6 66.1L55.9 63.9L60.2 63.7L60.7 60.2L68.9 59.6L69.3 57.5L70.2 59.4L77.4 52.1L84.7 50.3L83 48.6L86.7 50.1L90.6 46.7L92.3 42.5L90 42.9L93.2 41.7L90.8 38.5L94.7 40.4L97.6 37L95 35.3L98.3 35.9L93.3 33.1L98.9 34.2L100 32.5L98.6 29.7L95.3 29.9L99 28.8L95.3 28.2L99 23L92.1 20.1L92.4 16.8L87.3 17.7L89.9 12.2L84.7 9.5L90.7 3.6L86.8 3.9L82.4 8.6L79.9 7.2L78.4 1.1L75.5 0.2Z', w: 100, h: 67.5, b: [-24.53, -90.07, -13.49, -82.61] },
IT: { d: 'M27.3 2.4L27.2 5.8L25.7 4.8L24.2 5.7L25 8.9L23.7 7.4L21 8.3L20.1 6L18.9 6.1L18.6 8.9L16.9 10.9L17.1 13.1L15.4 11.4L15.8 10.4L12.9 8.7L12.9 6.5L10.4 8.4L10.8 9.8L8.9 12.1L6.5 11.4L1.4 12.9L1.3 14.1L4 17.3L3.2 19.3L0 20.2L3.2 24.5L1.6 26.1L1.8 27.8L5.1 30.2L7.5 29.6L6.4 33.5L10.4 32.4L12.9 28.5L15 27.1L18.8 28L25 31.5L27.8 38.8L27.4 41.8L29.4 41.9L29.1 43.1L32.5 45.9L31.7 47.1L35.5 47.8L37 50.5L39.2 51.6L39.7 53.3L45.6 58.2L47.2 57.5L49.2 58.4L50.4 57.9L52.7 62.3L54.4 61.8L55.7 62.8L54.7 64.3L58.1 63.5L59.5 65.9L58.8 67.3L61.4 69.5L64.3 69.4L68.2 80.5L65.5 82L66.1 83.2L64 85.9L64 87.8L64.8 88.7L67.1 88.7L67.7 86.8L70.7 84L71 80.4L73.3 79.4L74.4 79.9L75.2 78.6L74.5 77.9L74.8 75.2L70.1 72L72.9 65.6L74.6 64.8L77.3 66.9L79.7 66.9L80.9 69.9L83.2 71.6L84.5 68.7L84 67.2L80.4 63.2L66.4 56L65.8 54.5L67.9 53L67.7 51.7L61.9 52.1L62.8 51.7L57.6 50L52.6 44.6L49.7 35.8L40.9 29.3L39.9 24.3L41.2 22.4L41.4 23.3L41.8 21.5L40.2 20.6L39.2 17.5L39.9 16.7L40.8 17.2L40.2 16.6L41.6 15.6L42.4 16L41.2 17.2L44.6 14.7L45.9 15L46.6 13.5L48 14.6L49.4 13.4L51 15.1L50.4 15.4L51.7 15.1L49.3 12.9L49.8 11.4L48.7 11.3L50 9.4L48 8.3L50.4 5.9L41.2 4.2L39.2 1.9L39.7 0L36.5 1.3L32.2 1.3L31.3 3.3L27.3 2.4ZM18.5 57.8L13.8 61.9L11 60.8L10.7 64.1L12 64L13.1 66.9L12.5 70.5L13.8 70.8L12.4 74.5L13.1 78.2L12.2 78.2L12.7 79.3L13.2 78L13.9 78.5L14.3 80.1L17 79L16.9 76.4L20.6 78L21.3 76.2L22 69.2L21.3 67L22.7 64.7L22 61.7L20.4 61.1L21.6 60.3L20.4 60.2L20.5 58.8L20 59.4L18.5 57.8ZM63.3 85.2L61 85.5L60.2 86.9L58.8 86.2L55.8 87.7L50.8 88.2L48 86.9L47.5 85.9L45.9 86.2L45 87.7L43.3 86.2L41.1 89.7L42.9 91.9L46.4 92.4L50.5 95.6L54.5 96.5L55.9 98.8L60.1 100L60.6 97.7L61.9 96.9L60.1 92.4L64.1 85.6L63.3 85.2Z', w: 84.5, h: 100, b: [6.63, -53.51, 18.52, -39.44] },
JM: { d: 'M25.1 0.5L23.7 0L20.5 1L19.6 4.3L16.9 3.4L15.5 3.9L11.9 3.9L11.9 3.4L11.9 4.3L11.4 3.4L9.6 3.4L9.1 4.3L8.7 3.4L7.8 3.9L6.8 3.4L6.8 4.3L5.9 4.3L5.5 5.8L5.5 5.3L4.6 5.8L4.6 6.7L3.2 6.3L2.7 8.2L2.3 7.7L0.9 8.2L1.4 11.6L0 12L0 13L1.4 14.4L5 14.9L5.9 16.4L9.1 14.9L14.2 15.4L15.5 17.3L15.5 19.2L16.4 20.2L17.8 20.2L19.6 24L21.5 23.6L24.7 25L24.2 28.4L25.6 28.8L26.5 30.8L28.8 31.7L28.8 32.7L37 31.7L39.3 33.2L43.4 31.7L48.4 34.6L52.5 38.9L56.2 39.4L57.1 38L56.2 37.5L56.2 36.5L55.3 36.5L55.7 37L55.7 36.5L54.8 37L53 36.5L53.4 35.6L55.3 36.5L54.8 35.6L54.3 36L55.3 35.6L54.3 34.6L54.8 34.1L56.2 34.6L55.3 34.1L56.2 30.8L56.6 31.2L58 29.8L58.4 30.3L59.8 29.8L61.2 30.8L61.2 32.2L59.8 31.2L60.3 32.2L60.7 31.7L60.7 32.7L65.3 33.2L68 29.8L68.5 27.4L69.4 27.4L69.4 25.5L70.3 25.5L70.3 26.4L71.7 27.4L74.4 26.9L75.3 27.9L74.4 28.4L69.9 27.9L70.3 28.4L69.4 28.8L69.9 28.4L73.1 28.8L75.3 27.9L78.1 28.4L80.4 31.7L83.1 31.7L83.6 32.7L88.6 31.2L92.7 32.2L93.2 30.8L93.6 31.7L94.5 31.7L95.4 30.8L96.3 31.2L98.6 29.8L98.6 30.3L100 29.8L99.1 29.3L99.1 28.4L97.3 27.9L97.3 26.4L95.4 24L95.9 23.1L93.6 20.7L92.7 17.8L92.2 18.3L90.9 16.8L89 17.3L88.6 16.4L86.8 16.8L86.3 15.4L82.2 15.9L80.8 14L80.8 14.4L78.5 14.4L76.7 12.5L75.3 13L74 12L72.6 12.5L70.8 9.1L67.1 7.2L67.6 5.8L66.2 5.3L64.8 6.3L61.6 5.3L56.6 5.8L54.8 4.3L50.2 2.9L43.8 3.4L42.9 2.4L41.6 2.9L38.4 1.9L34.7 2.4L33.3 1.9L34.2 1.9L33.3 2.4L32.9 1.4L30.6 1.9L27.9 0.5L25.1 0.5Z', w: 100, h: 39.4, b: [-78.37, -18.86, -76.18, -18] },
JO: { d: 'M86.7 29.7L88.7 27.7L87.9 24.6L83.4 26L82.4 21.7L84.4 21.2L78.3 0L50.3 17.5L49.7 18.3L40.3 24.1L38.4 25.8L29.4 24.1L25.3 20.5L24.7 20.9L23.7 20.5L22.9 20.9L22.5 19.2L21.7 18.5L21.9 17.3L20.4 17.3L20.2 16.8L20.6 16.6L20 16.3L20 15.8L18.2 15.8L17 15.4L17.2 14.9L16.4 15.1L15.7 15.8L15.5 15.6L15.3 16.1L14.7 16.1L14.5 16.8L13.3 16.8L13.3 17.5L12.9 17.8L12.3 17.5L12.7 18.8L12.7 20L12.3 20L12.5 20.7L12.1 20.7L12.7 21.2L12.3 23.1L12.1 22.9L12.5 28.7L12.3 29.2L12.1 28.9L12.3 29.9L12.1 29.7L12.1 30.4L11.6 30.6L12.1 31.3L11.6 31.3L11.9 31.6L11.4 32.1L11.4 33L11.6 32.8L12.1 34L12.1 34.7L11.4 35.4L12.1 36.4L11.9 38.1L12.3 39.3L10.6 43.4L10.4 48.2L9 50.6L9 51.7L10 53.2L10.2 54.6L9.4 56.3L9.4 58.4L8 59.4L7.6 60.3L7.8 62L7.4 62L7.2 63L6.7 63L6.7 64.1L6.1 64.9L6.1 65.6L5.3 66.3L4.7 69.6L4.1 70.3L4.1 71.5L4.7 72.4L4.7 72.9L3.9 73.6L3.7 77.2L4.1 77.7L4.1 78.6L3.9 79.5L3.5 79.5L2.9 80.5L3.3 81L2.5 81.9L2.5 84L1.2 86.9L1.2 89.4L0.4 90.6L0.8 92.5L0 94.1L0 96L21.3 100L22.9 100L31.5 93L36.6 84L51.9 81L55.4 73.2L62.1 69.1L57 63.2L56.8 62.5L41.7 45.3L67 37.6L82.4 33.5L86.7 29.7Z', w: 88.7, h: 100, b: [34.96, -35.43, 39.3, -30.54] },
JP: { d: 'M58.8 28.7L58 31.5L60.1 30.4L60.3 32.8L58.5 32.3L57.8 33.6L57.3 31.1L55.8 30.6L55.3 33.8L53.3 35L53.9 38.5L52.4 39.2L52.7 40.2L54.4 39.2L53.2 46.6L51.1 51.5L46.5 56.6L39.7 60.8L37.8 58.3L40.3 55.7L37.3 56.6L36.9 60.8L33.1 65.5L33.7 67.6L29.8 69L29.4 66.9L20 69L18.3 68L9.6 75.5L7.3 75.3L7 78.7L11.4 77.7L13.4 79.2L14.7 75.7L15.6 77.7L16.8 76.2L21.5 74.6L22.7 75.3L22.8 74.2L26.4 73.1L30.5 73.8L28.5 76.2L28.5 78.8L32.3 81.6L35 76.8L38 76.4L35.9 74L37.1 71.4L38 71.3L38.2 73.7L38.4 72.1L38.7 73.3L40.4 73.2L38.6 74.5L41.7 73.1L44.9 74.3L47.7 71L48.1 74.3L49.7 70.2L51.7 69.9L52.4 70.9L52.1 68.9L53.7 67.4L54.7 68.2L52.8 69.8L52.7 72.1L55.7 70.9L56.4 68.4L58.5 67.3L56.9 63.5L59.3 58.1L58.8 51.2L60.5 49.8L61.9 50.7L61.5 48.1L64.7 42.3L61.4 34.4L61.6 29.5L58.8 28.7ZM64.2 0.4L62.5 0.8L62.2 2.4L63.3 7.2L62.5 11.9L60.9 13.2L61.3 16.4L59.9 17.4L55.8 16.1L56.8 18.5L53.1 21.2L52.8 23.7L54.8 25.9L54.4 29.5L57.3 26.8L60.2 26.9L55.5 23.7L56.5 21.4L59.3 23.4L62.7 21L70.8 26.1L71.6 22.4L74.7 19L78.7 19L84.1 15.8L82.5 17L81.1 16.2L81.9 16.4L80.9 14L81.7 14.4L80.2 12.7L81.6 8.8L78.6 11.9L73.3 10.6L64.2 0.4ZM7.5 78.3L5 78.8L2.5 81.5L1.6 80.8L1.6 82.6L0.1 82.3L2.4 85.2L0.7 83.7L1 86.9L2.7 85.6L3.2 86.8L4.1 86.3L2.9 83.5L4.7 82.4L5.6 86.1L4.7 86.6L5.7 86.6L3.2 89.8L4.1 93L2.9 94L5.4 95.5L5 93.5L6.5 92.3L5.4 92.9L6.5 94.4L5.7 96.6L8.2 94.8L7.8 93.8L9.3 94.3L11 86.8L13.1 84.7L12 84.6L12.2 82.6L10.1 82.6L11 80.1L8 80.5L7.5 78.3ZM23.7 75.6L20.8 76.4L20.5 78.3L18.5 78.7L17.3 77.3L16 80L12.8 82.1L14.7 81.3L15.9 85.8L17.9 86L17.8 84.2L20.8 80.8L23.9 82.8L26.9 79.2L26.1 76.6L23.7 75.6ZM99.5 0L96.5 2.2L95 0.9L89.5 8.2L93.6 4.1L99.7 1.7L99.5 0Z', w: 100, h: 96.6, b: [129.55, -51.29, 148.89, -32.62] },
KE: { d: 'M21 4.1L19.2 4.1L19.1 3.5L18.4 4.5L17.4 4.2L16.6 2.8L17.1 1.3L15.2 1.1L15.4 0L4.8 4.2L0.8 8.4L1.6 9.2L1.5 10.5L2.3 11L1.9 12.1L3.2 11.9L2.6 13L3.5 12.9L4 13.9L4.8 13.4L5.7 14L5.6 15.7L4.9 15.9L5.7 19.2L6.6 19.6L7.1 21.8L8.8 22.8L9.2 24.5L9.7 25.3L10.2 25.2L10.8 26.4L10.3 27.2L11.5 32.6L10.8 35.3L9.8 36.1L9.7 37L8.8 37.2L9.5 38.8L6.3 40.5L5.6 43L4.1 44L3.8 45.2L2.4 45.8L2.1 48.1L0 50.7L0.7 53.1L0.2 62.1L2 62.4L37.9 82.7L38.7 83.3L39.1 85.5L37.9 87.2L38.1 88.1L39.4 88.2L40 89.8L54.4 100L55.4 99.1L55.4 99.6L56.5 99.7L56.4 99.1L56.8 98.6L57 99.1L57.6 97.3L57.9 97.5L59.4 93.8L58.1 93.9L58.2 93.2L59.2 93.8L58.9 92.8L59.4 93.6L59.8 93.4L60.2 92.6L59.6 92.2L60.2 92.6L60.9 91.2L62.2 86.1L62.4 86.7L64 85.6L64.4 83.1L65.1 82.7L64.4 82.8L64.3 82.3L64.7 79.8L66.2 78.4L67.9 77.7L68.9 78.1L71.2 76.4L70 75.5L70.9 75.8L70.4 75.1L71 75.6L71 74.9L71 75.4L71.7 74.5L72.3 74.6L71.7 73L72 72.5L71 72L72.1 72.4L72.2 73.2L72.7 73.1L73 71.4L73 73L74.1 72.2L74.9 72.4L75 71.9L74.6 71.9L75 71.5L75.2 71.9L75.2 71.4L75.8 72.3L78.7 69L78.7 68.3L72.8 60.4L72.8 22.8L76.5 19.1L82.3 10.8L74.7 11.2L70.5 7.6L61.4 11.9L60.3 14.1L57.9 16.8L57.5 16.1L55.8 16.1L55.8 15.7L54.3 16L53.2 15.3L51.1 15.6L49.5 15.1L49.2 14.4L47.9 14.6L47.6 14.1L46.9 14.6L43.3 14.6L42.2 13.4L32.1 6.6L30.2 6L22 5.9L20.9 5L21 4.1Z', w: 82.3, h: 100, b: [33.91, -5.04, 41.91, 4.69] },
KG: { d: 'M99.5 13.1L97.5 10.3L93.4 10.1L90.4 7.2L90.6 5.9L88.2 6.2L85.7 5.7L84.2 4.6L79.2 5.2L75.8 4.1L72.6 4.5L70.6 3.5L64.7 5.1L64 4.4L61.7 4.5L60.7 3.9L58.4 5.9L54.4 5.2L51.2 3.4L50 3.5L48.5 1.7L45.2 0L45.2 1L43.6 1.2L43 0.6L42.4 2L41.6 1.7L39.1 3L38 7.8L38.7 10.7L37 10.3L37 9.4L32.5 8.8L31.8 7.7L29.6 7.5L27.4 6.2L26 6.6L23.6 5.4L21.5 6.2L19.4 5.9L18.3 6.2L17.4 8.6L15.9 8.5L16.8 9.8L15.8 9.6L14.8 11.7L14.9 12.5L17.2 12.3L18.3 13.6L15.9 15.4L14.7 15.1L14.5 16.7L12.8 16.8L11.4 19.1L8.7 20.5L8.4 21.7L9.7 21.6L11.2 23L13.4 22.2L14 25.6L14.7 24.9L15.6 26L16.3 25.6L17.1 26.2L17.8 25.5L18.6 26.4L19.4 25.8L19.6 26.6L19.9 24.4L21.8 23.8L22 22.3L21.4 22.1L22.6 21.2L22.1 22.8L23.1 22.4L24 23.5L24 26L25.6 26.4L26.8 26L26.6 28L28.2 27.2L29.6 27.8L30.5 29.6L32.9 29.5L33.5 30.3L35.2 29.7L35.4 30.5L32 31.7L32.1 33.1L31.1 32.8L31.1 33.8L28.1 32.7L29 33.4L28.8 35.2L27.2 34L25.8 34.4L24.7 36L25.3 36.8L23.7 36.8L23.9 37.4L22.7 38.1L22.1 36.6L21.1 37.4L17.8 35.7L15.6 36.5L15.7 37.8L14.1 37.7L12.8 38.8L12.8 40L11.4 40.7L12.1 39.9L11.7 39.3L7.5 37.4L4 38.9L2.6 38.4L2.6 40.7L1.6 40.8L0.8 39.6L0.8 40.9L0 41.4L0.8 45.3L4.2 44.6L5.6 45.4L7.7 44.7L9.7 45.4L10.2 44.6L11.7 44.4L13.8 45.9L13.8 47L15.2 47.1L15.4 46.5L16.5 46.8L16.8 45.5L18.6 45.7L20.4 44.2L21.1 45L21 46.3L23.1 46.3L23.2 48.5L25.5 47.3L27.1 49.6L27.9 47.9L29.4 47.2L36 47.6L38.4 46.1L41.8 46.1L42.8 44.6L41.7 42.5L43.1 39.9L42.6 39.5L45.2 38.3L46.4 38.8L48.5 36.5L49.5 36.5L49.5 35.7L51.5 35.8L50.5 34.6L50.8 33.7L54.4 34.6L57.8 32L58.2 33.8L58.9 34.1L58.7 36.5L59.8 35.9L60.7 36.4L61 35.3L63 35.5L63.9 34.6L64.6 35.8L66.4 34.4L67.4 32.5L67.3 30.7L69.4 27.5L70.7 27L73 27.9L77.6 27.8L78 26.2L80.9 25.1L81.2 23.2L85.6 22.2L86.1 21.2L92.4 17.7L95.8 17.1L96.8 15.2L99.8 15.2L99.5 13.1Z', w: 100, h: 49.6, b: [69.25, -48.09, 80.23, -42.65] },
KH: { d: 'M98.7 0L96.8 1.6L96.4 3.3L93.6 2L91.5 5.3L89.6 5.8L88.9 4.7L87.5 7.4L84.5 7.4L83 4.9L81.1 4.9L80.6 3.5L79.8 3.7L79.6 2L77.9 2.5L78.3 3.5L77.4 3.3L77 4.7L75.1 4.9L74 3.9L72.8 6.6L69.2 6.2L69.8 9L72.8 12.3L71.1 13.6L71.3 15.2L67.4 14.8L65.3 13L65.1 11.7L62.5 11.5L61.1 10.3L57.2 11.5L54.3 8.2L54.2 6.6L52.8 9.2L51.3 9.4L50 5.8L46.8 4.9L45.7 5.7L43.4 5.3L42.1 6.6L36.6 5.5L30.6 7L25.8 6L25.8 4.9L24.5 5.7L23.2 5.1L21.5 6.4L20.6 5.8L15.8 6.8L11.7 9.4L10.9 13L8.5 14.8L7.5 17.9L4.3 19.5L5.5 21L0 22.4L0.4 27.6L1.9 31.1L3.8 32.6L3 33L4 36.9L3.4 39.2L6 40.4L8.7 44.1L7.4 45.2L7 48.9L10.9 56.8L11.7 60.6L11.9 58.5L12.8 58.9L11.9 61.2L13.6 60.6L13.6 61.8L15.1 62.4L14 63.3L15.5 65.7L14 65.8L13.6 67L14.9 68.5L14.9 73.9L16.2 74.1L17.9 72.8L19.4 74.1L20.8 73.5L20.9 69.5L21.9 68L23.2 68.2L24.2 69.9L26 70.1L24.9 70.5L26.2 73.9L25.5 76.4L23.6 76.4L21.9 78.7L24.3 80.3L24.2 81L26.6 81L26.2 79.9L27.9 80.5L29.1 78.9L28.9 77L30.4 79.3L34.9 80.3L36.4 79.7L37.2 81.6L38.3 81.2L40.9 82.9L42.6 80.5L47.9 80.6L52.3 76.8L50.9 73.9L51.9 72.2L56.8 74.1L58.3 72L60.6 72.4L65.1 70.8L66.4 74.1L68.1 74.5L68.1 73L70.6 75.1L72.8 75.5L71.9 73.2L71.9 71.8L73.2 71.8L72.8 70.3L72.3 69.5L71.1 70.1L69.4 67.8L67.7 67.2L67.5 65.8L66.8 66L67.2 61.2L65.7 60.5L65.7 59.5L67 58.3L68.5 59.1L69.8 56.4L74.2 57.4L74.9 58.5L77.7 58.5L77 56.8L78.1 54.7L77 52.7L77.5 52.4L82.5 52.9L84.2 50.6L88.1 50.4L91.7 46.4L94.7 45.8L96 47.5L98.5 45.4L99.1 38.8L97.2 33.8L100 25.7L99.8 22.6L98.3 18.5L96.6 17.5L96.8 13.8L94.9 13.2L94.3 11.3L95.8 9L95.5 7L96.6 5.3L97.4 5.7L98.7 0Z', w: 100, h: 82.9, b: [102.33, -14.85, 107.63, -10.46] },
KM: { d: 'M9.9 0L6.9 0L6.9 0.8L3.8 1.6L3.1 3.1L3.1 6.2L2.3 6.2L3.8 9.3L3.1 17.1L2.3 17.1L3.1 17.1L2.3 17.1L2.3 22.6L3.1 22.6L2.3 23.4L3.1 23.4L1.5 24.2L1.5 26.5L1.5 25.7L0 27.3L0 30.4L2.3 32.7L1.5 32.7L2.3 32.7L2.3 34.3L4.6 37.4L6.1 37.4L6.1 38.2L9.9 38.2L9.9 37.4L16 43.6L19.1 44.4L20.6 42.9L20.6 41.3L22.1 40.5L19.1 37.4L19.1 35.8L16.8 32.7L17.6 31.9L16.8 29.6L17.6 29.6L15.3 26.5L15.3 24.2L13 22.6L13 19.5L12.2 19.5L13 17.9L12.2 17.1L13 17.1L13 14.8L13.7 14.8L13 14.8L13.7 14L13 14L13 12.5L13.7 12.5L13 8.6L13.7 8.6L14.5 4.7L13.7 4.7L13 0.8L9.9 0ZM96.2 58.5L96.2 56.1L94.7 54.6L93.1 54.6L92.4 56.1L90.8 56.1L91.6 57.7L90.8 61.6L89.3 61.6L88.5 63.2L81.7 64.7L80.9 63.2L78.6 63.9L77.1 62.4L74 62.4L79.4 65.5L79.4 66.3L80.9 66.3L80.9 67.1L88.5 68.6L88.5 70.2L90.1 71.8L91.6 71.8L91.6 73.3L93.9 74.1L96.2 76.4L96.2 78.8L99.2 78.8L100 75.7L99.2 75.7L99.2 71.8L98.5 71L100 69.4L99.2 69.4L98.5 63.2L96.9 62.4L96.2 58.5ZM32.8 68.6L29.8 70.2L30.5 70.2L29.8 71.8L31.3 73.3L30.5 74.1L32.1 74.1L33.6 75.7L32.8 77.2L36.6 77.2L36.6 78L40.5 77.2L42 78.8L42 78L44.3 78L45 78.8L44.3 78.8L48.9 79.6L48.9 77.2L45 74.1L42 73.3L38.9 70.2L36.6 70.2L33.6 68.6L32.8 69.4L32.8 68.6Z', w: 100, h: 79.6, b: [43.23, 11.45, 44.54, 12.49] },
KN: { d: 'M17.4 0L14.5 0L11.6 3L5.8 3L5.8 6.1L2.9 6.1L2.9 9.1L0 9.1L0 15.2L2.9 18.2L5.8 18.2L5.8 24.3L8.7 24.3L11.6 27.3L14.5 27.3L17.4 30.3L20.3 30.3L20.3 33.4L23.2 33.4L23.2 36.4L26 36.4L26 39.4L46.3 39.4L46.3 42.4L52.1 42.4L52.1 45.5L57.9 45.5L57.9 48.5L60.8 48.5L60.8 57.6L57.9 57.6L60.8 60.6L60.8 63.7L63.7 63.7L63.7 60.6L66.6 60.6L66.6 57.6L69.5 57.6L69.5 60.6L69.5 57.6L72.3 54.6L69.5 54.6L69.5 51.5L63.7 51.5L66.6 51.5L63.7 51.5L63.7 48.5L60.8 48.5L60.8 45.5L57.9 45.5L57.9 42.4L55 42.4L52.1 39.4L55 39.4L52.1 36.4L52.1 33.4L49.2 30.3L49.2 27.3L46.3 27.3L46.3 24.3L43.4 24.3L43.4 21.2L37.6 15.2L37.6 12.1L31.8 12.1L31.8 9.1L28.9 9.1L26 6.1L23.2 6.1L23.2 3L20.3 3L17.4 0ZM83.9 63.7L78.1 63.7L78.1 66.7L72.3 66.7L75.2 66.7L72.3 66.7L72.3 72.7L69.5 72.7L69.5 87.9L66.6 87.9L69.5 90.9L69.5 93.9L72.3 93.9L72.3 97L75.2 97L75.2 100L81 100L81 97L86.8 97L89.7 93.9L89.7 97L89.7 93.9L92.6 93.9L92.6 87.9L95.5 87.9L95.5 78.8L92.6 78.8L92.6 69.7L86.8 69.7L86.8 66.7L83.9 66.7L83.9 63.7Z', w: 95.5, h: 100, b: [-62.87, -17.69, -62.54, -17.35] },
KP: { d: 'M0.7 57.6L0 59.3L1.4 61.2L3.7 60.6L4.4 65.1L6.3 64.1L6.3 61.5L11.7 65.4L13.3 64.3L13.9 66.2L16.9 63.8L17.9 63.6L16.3 65.2L18.6 64.3L14.7 67.3L15.1 69L16.9 68.6L15.6 69L16.3 70.1L11.7 78.2L12 80L13.9 80.4L13 81.1L16.3 80.2L18.4 82.6L16.1 81.1L14.3 82.7L11.6 81.8L11.9 83.8L9.3 83.5L10 85.3L7.9 87.9L8.3 90.2L9.9 90.2L7.9 89.9L4.9 91.9L8.1 91.7L9.9 93.1L13.6 92.6L13.4 93.8L11.4 93.3L12.1 94.9L10.7 94.4L9.6 95.7L12.6 96.6L13.3 95.1L13.9 97.8L14.9 96.4L16.3 96.9L15.3 95.8L17.3 96L14.7 100L17.1 97.7L18.7 98.6L18.9 95.5L20.4 95.5L18 93.5L21.6 94.4L22.6 97.1L24.9 96.4L25.4 98.7L29.7 95.7L32.7 98.4L34 97.1L33.7 94.9L35.9 94.2L40.3 88.6L54.7 88.2L56.7 86.4L57.9 81.6L56.7 80.4L55.4 81.1L49.1 73.6L48 74.1L43.7 71.9L44 69.7L45.3 69.7L44 68.8L45.7 68.4L46.6 70.4L45.6 62.5L49.6 59.3L51.1 59.5L52.6 56.3L57.4 56.9L58.1 55.4L62 53.9L61.9 51.5L68.3 48.5L70 44.3L71.4 44.7L72.3 42.8L77.3 41.9L77.1 35.6L78.6 31.4L76.3 29.5L76.7 26.7L82.4 17.8L83.7 18.2L84.1 16.1L87 14.8L86.9 13.2L91.3 14L88.7 7.8L87.4 9L87.6 7.8L84.9 5.8L85.1 2.1L82.7 2L83.4 0.6L79.6 0.2L77.1 11.3L75.6 10.7L74.6 12.6L71.9 10.9L69.7 13.4L70.1 15.7L66.4 17.8L66 19.4L53.1 19.6L54.1 23.6L57.1 27.2L54.4 31.8L52.9 30.1L47.6 31.1L40.4 28.8L41 27.2L37.6 23.2L35.6 25.5L34 24.4L34.3 25.5L32.1 26.9L31.7 32L30.3 32L28.3 35.6L25.9 36.9L24.4 40.5L19.9 41.1L19.6 43.2L17.7 43L15.7 45.8L13.9 45.1L10 47.5L10.4 48.8L8.3 48.5L1.9 54.3L0.7 57.6Z', w: 91.3, h: 100, b: [124.31, -47.73, 130.7, -40.73] },
KR: { d: 'M8.6 14.8L8.5 18L11.8 20.5L8 18L7.9 15.9L6.1 16.1L7.6 23.5L10.7 25.1L7.8 25.1L8 27.6L9.4 28L10.1 30.8L13.1 31.9L11.2 32.8L11.2 34.5L10 30.9L7.8 31.3L5.8 29.4L6.6 31.9L5.7 31.1L5.4 32.8L5.1 30.4L3.3 30.6L4.8 32.2L3.1 33.2L3.6 34.5L2.4 34.3L2.8 30.9L2.4 33L1 32.6L1.3 34.7L0.6 33.4L0 34.9L0.1 36L0.6 34.7L1.6 35.6L0.6 36.5L2.4 35.6L3.3 41L4.5 41.7L3 36.3L3.6 35.6L4.5 38L5.1 36L5.8 41.2L6.9 40.2L5.5 41.9L7.2 45.8L5.7 46.7L7 46.5L9.2 49.1L11.6 46.5L9.4 49.5L6.1 49.7L7.9 51.3L10.9 50.8L8.6 51.9L10 53.7L7.8 52.8L5.2 55.7L5.8 56.8L8.5 56.6L8.5 57.7L7.8 56.8L7.3 58.1L5.5 57.9L5.5 59.8L4.3 60.1L5.7 61.4L4.2 60.7L2.8 63L5.5 65.8L4.6 67.1L3.6 64.5L1.9 65.2L2.2 66.5L2.8 65.4L4.2 66.7L2.8 68.7L4.3 67.2L4.9 68L3.7 71.4L6 71.6L6 68.7L6.6 70.7L7.8 70.7L3.7 72.1L7.6 74.1L4 72.5L4.8 74.9L3 71.8L2.1 73.2L4.2 75.9L6.4 74.9L6.6 76.1L5.1 76.7L6.1 80.3L9.4 77.4L9.8 74.3L10 77L11.5 77.9L13 77L12.7 74.1L15.9 73L16.8 71.4L18.2 72L18 73.4L17.1 72.5L14.9 75.6L16.5 75.4L18.3 77.6L18 76.5L19.8 75.9L18.5 74.9L20.9 74.9L18.6 70.3L21 69.4L22.2 72L21.5 73.6L23 74.1L23.1 71.8L24.9 73L23.8 74.3L25.3 74.9L24.1 72.1L24.9 70L22.1 69.8L22.2 68.7L24.9 69.4L24 67.4L26.2 68.5L27 67.2L28.3 67.8L29.1 65.8L28.9 68.7L31.2 69.4L33.1 68.1L32.8 69.6L34.6 70L33.1 70.7L34.4 71.8L35.3 69.4L34.4 69.8L34.3 67.4L35.6 67.2L34.9 65.6L37.7 66.1L37 63.6L38.5 65.6L41.3 66L42.5 64.9L42.6 66.9L43.5 65.6L44.4 66.5L43.8 65.1L45 65.6L48.4 60.9L48.3 57.4L49.8 58.5L51.7 49.1L51.4 47.4L49.8 49.3L48.6 48.2L50.2 34.9L48.4 26.1L37.4 9.3L33.5 0L32.3 3.6L29.4 5.9L15.2 5.9L10.6 11.8L8.3 12.5L8.6 14.8ZM9.7 93.2L3 95L0.9 98.6L2.4 100L10.9 97.9L12.5 95.2L9.7 93.2Z', w: 51.7, h: 100, b: [126.11, -41.93, 129.58, -35.22] },
KW: { d: 'M0 61.2L48.9 67.3L53.7 75.8L55.3 77L56.4 88.5L61.7 95.2L61.2 96.4L100 95.8L97.9 91.5L98.4 87.9L97.3 86.7L97.3 84.3L98.4 83.1L95.7 83.1L92.6 80.6L92.6 74.6L88.3 72.7L88.3 70.9L86.2 69.1L84 56.9L81.9 51.4L82.4 45.9L79.8 46.5L77.1 44.1L77.7 43.5L76.1 43.5L73.9 45.9L71.8 45.3L72.9 45.9L70.7 47.8L68.1 47.8L67.6 44.7L69.1 43.5L67 44.7L61.2 44.7L63.3 42.3L64.4 42.9L69.1 36.2L74.5 32.5L79.3 31.9L83.5 33.1L85.6 36.2L87.2 34.3L82.4 23.9L83 22.7L79.8 20.3L78.2 16.6L78.2 11.1L77.1 8.6L77.7 6.8L75.5 6.1L75.5 7.4L75 5.5L62.8 0L43.1 0L39.9 2.5L33 4.9L29.8 9.8L23.9 24.5L23.9 27L18.6 35.6L17 40.4L12.2 45.3L11.7 47.8L5.9 55.7L2.1 58.1L0 61.2ZM85.6 7.4L83 7.4L83 8.6L85.1 10.4L85.1 12.9L86.7 11.7L87.2 12.3L87.2 9.8L87.2 11.1L88.3 11.1L86.7 12.9L87.2 13.5L86.2 13.5L87.2 14.1L86.2 14.1L86.2 12.9L85.6 14.1L84 13.5L86.2 14.1L86.7 14.7L85.6 15.4L86.7 15.4L86.7 16.6L85.1 14.1L83.5 14.1L83 14.7L85.1 16L81.9 15.4L80.9 16.6L81.4 17.2L83 16L82.4 16.6L84.6 17.2L82.4 16.6L81.4 17.2L82.4 17.8L81.4 17.2L80.3 19.6L83 20.9L86.2 30.1L89.4 31.9L95.2 26.4L97.3 22.1L97.3 19L94.7 17.2L91.5 11.1L89.9 10.4L88.8 8.6L85.6 7.4Z', w: 100, h: 96.4, b: [46.55, -31.59, 48.43, -29.78] },
KZ: { d: 'M83.1 47.1L84 46.5L83.9 45.5L83.1 41.5L81.8 40.4L86.4 38.8L88.3 39.5L87.8 36.9L89.5 32.3L94.2 33.6L95.7 32.9L96.1 28.2L98.2 27.5L98.6 25.7L100 24.9L98.3 23.7L98.5 22.8L97.3 24L95 23.6L94.4 21.8L92.7 21.2L91.8 19L90.5 18.1L88.8 18.5L88.4 19.2L85.9 19.2L85.5 18.3L84.7 18.4L84.9 17.4L83.7 16.9L82.3 19.2L76.9 9L73.3 5.6L74.1 5.4L74.6 4.2L70.8 5.8L69.9 7L68.5 7.4L68.3 8.3L67.1 7.6L66 8.4L65.5 7.5L66.8 5.9L65.6 6.4L64 5.5L64.2 6.3L63.6 6.5L63.7 5.5L62.6 4.6L62.9 5.6L61.9 5.1L61.8 5.6L60.5 5.7L60 4.7L60.5 4.8L60.7 3.3L59.6 0.6L58.1 1.2L55 0L55.1 0.6L54.5 0.3L54.2 1L53.1 1L53.2 2.1L46.5 3.3L45.9 4.7L41 5.3L39.5 5.8L39.3 6.5L39 5.9L37.8 6.3L36.5 5.7L35.6 6.3L36.1 7L35.3 7.6L37 8.1L36.2 8.1L36 9L37.2 9.1L38.3 10.3L35.6 10.3L34.8 11.6L35.7 12.9L33.1 14.4L34.4 15L34.1 15.6L37.2 17.2L36.6 19L34.2 19.5L33.2 18.8L31.9 20.2L31.8 19.5L29.8 18.9L29 17.5L27.6 17.6L27 18.6L26.2 17.7L25.1 18.3L24.5 17.8L22.6 19.9L20.1 18L19.7 20L19.7 18.8L16.9 16.3L14.8 16.4L14.4 15.1L12.1 16.3L12 15.7L10.5 15.2L9.5 16.9L7.3 17.7L7.2 18.8L5.8 19.7L5.2 19.5L5.6 22.3L4.3 22.6L2.5 20.3L1 22.6L0.7 24.5L1.4 25.2L0 27.9L1.5 28.5L1.7 30.4L3.8 30.3L5 31.6L6.2 34L5.1 34L5 34.7L7.7 35.7L9.1 34L11.7 33.1L12.9 34.1L12.8 33.6L15 33.6L15.9 34.3L15.3 38L15.9 38.8L12 38.9L10.7 40.8L12.3 41.9L9.1 41.6L9.2 42.4L10.6 42.9L11.7 46.5L15.3 47.9L14.6 51.1L15.9 49.9L18.9 49.1L21.8 52.6L23.3 52.5L23.3 40.2L29.6 38.1L35.5 42.1L38 45.3L45.3 44.6L47.4 47.5L48 47.2L47.8 50.5L49.1 50.7L49.5 53.1L52.6 53.1L52.5 54.2L53.9 55L54 53.5L58.5 50.1L59.1 50.2L61 47.7L66.2 49L66.3 46.9L67.9 46.1L71.4 47.7L74.7 47L80.1 47.7L82.5 49.6L82.7 47.6L83.5 47.4L83.1 47.1Z', w: 100, h: 55, b: [46.49, -66.9, 87.32, -44.46] },
LA: { d: 'M82.5 91.1L82 89.9L82.9 88.8L81.5 87.4L83.2 87L83.2 82.8L80.5 82L78.7 79.1L78.8 78L81 77.2L81.4 75.2L80 75.6L78 74.1L77.9 72.9L76 72.7L74.7 69.8L73.7 71.3L72.7 71L72.7 69.5L71.5 68.8L71.4 64.5L70.2 64.7L68.6 61.2L68.1 61.9L66.9 61L62.6 56.9L59.7 50.7L58.3 51.2L57.9 49.9L56.3 49.3L55.4 47.8L56.5 45.9L55.8 44.8L51.6 43.9L45.2 39.1L41.8 37.8L44.6 35.7L44 33.5L47.9 33.1L50.4 34.2L51.1 32.6L52.5 32.2L52.5 30.5L54.2 28.5L50 26.9L50.1 25.4L51.2 25L50.6 24.2L48.7 25.4L47.3 24.3L50.3 21.8L44.5 18.2L41.2 19.5L39.9 22L37.4 19.9L36.5 20.5L33.4 19.1L31.2 15.2L30.1 15L31.1 14.4L32 9.2L30.7 9.5L30.3 7.9L30.3 9.3L28.6 10.2L28 7L22.5 0.8L20.2 0.8L20 1.6L18.6 0L17.5 0.4L16.2 3.3L16.8 6.4L18.8 8.1L18.3 9.2L19.3 10.8L18.4 11.1L18.2 14.2L19.4 15.5L18.5 16.3L16 14.9L12.8 15.6L11.7 13.2L12.5 11.3L11.7 11.3L10.2 13.3L7.1 14.3L4.7 19L6.3 19.4L1.9 20.9L0 25.5L1 26.9L2.8 25L4.1 27.4L5.5 27.7L3.5 32.6L4.7 35.3L6.3 34.9L7.7 35.7L8.9 34.1L13.3 34.8L12.2 37.1L12.9 39.9L14.1 40.8L12.9 42.5L13.1 45.2L10.7 47.9L12.1 49.1L12.1 52.5L10.4 54.2L9.8 58L11.9 59.2L14.6 56.1L15.6 56.3L16.4 54.6L18.2 54.1L18.8 52.2L20.2 52.7L22.1 50.5L25.9 53.3L27.8 53.5L27.7 54.9L28.8 55.4L29.9 53.8L32.9 53.1L33.9 50.3L35.5 49.6L34.9 49L35.5 48.1L38.8 48.3L41.7 49.8L42.9 49.1L46.2 54.6L52.1 60L51.4 70L54.5 73.4L54.7 75L58.8 75.9L58 77L58.6 78.4L61.3 80L60.8 82.8L59.5 83.5L60.7 83.8L59.5 86.2L61.1 87.8L59.9 89.9L60.2 92.4L59 94.2L56.6 95.1L57.3 96.9L58.3 97.8L62.3 97.7L64.2 99.8L66.5 100L67.4 98.3L65.3 94.8L67.4 95L68.1 93.4L69.8 93.9L71.4 92.3L74.3 95.4L76 95.4L76.8 93.9L78.3 94.2L79.5 92.3L81.2 93.1L82.5 91.1Z', w: 83.4, h: 100, b: [100.08, -23.11, 107.64, -14.05] },
LB: { d: 'M27 89L27 86.6L28.5 87.2L29 86L31.6 84.8L31.1 83.6L32.6 84.2L36.7 82.3L36.7 79.9L37.7 78.7L40.7 77.5L41.8 75.6L42.8 76.2L42.8 72L43.8 72L44.3 70.7L45.8 70.7L46.9 72L48.4 69.5L46.9 66.5L42.8 65.9L42.8 63.4L44.8 61.6L44.3 59.1L45.8 58.5L45.8 57.3L47.4 57.3L48.9 54.2L50.4 54.2L52.5 52.4L56 52.4L57.6 53.6L58.1 51.8L60.1 51.8L61.6 53L65.7 53.6L64.7 50.5L60.1 48.7L60.1 47.5L62.6 45.6L63.7 43.2L64.7 43.8L64.7 42.6L66.2 42.6L65.7 40.7L66.7 40.7L66.7 39.5L71.3 39.5L72.3 38.3L71.8 36.4L73.3 34.6L74.9 34.6L75.9 30.9L77.4 30.9L76.9 29L75.4 28.4L76.4 23.5L74.4 23.5L72.8 19.8L74.4 18.5L74.4 17.3L72.3 15.5L71.8 16.1L70.8 14.2L69.3 14.2L68.8 11.1L63.2 11.8L62.6 10.5L66.7 8.7L66.2 5L69.3 6.2L69.3 3.1L67.7 4.3L66.2 3.1L65.2 3.7L63.7 2.5L63.7 0L62.1 0L61.1 1.2L61.1 3.7L46.9 3.7L46.3 2.5L44.8 2.5L45.3 9.9L39.7 14.2L36.2 14.2L36.7 17.3L34.1 19.2L32.6 19.2L31.6 23.5L29 23.5L28 25.3L28.5 28.4L27 30.9L28 37.6L26.5 41.3L27.5 41.3L27 42L28 42.6L27 43.8L26 43.2L23.9 48.7L18.8 48.7L19.4 55.5L17.8 57.3L17.3 61L15.8 61.6L16.3 63.4L15.3 64L13.2 72L8.7 76.9L7.6 82.3L6.1 86L5.1 87.2L4.6 86.6L5.6 87.8L4.6 93.3L3.6 93.3L3.1 95.1L0 98.2L7.6 98.2L11.2 97L12.2 100L16.3 100L17.8 98.2L20.4 98.2L20.4 96.4L21.9 96.4L23.4 86L24.4 86L24.4 87.2L27 89Z', w: 77.4, h: 100, b: [35.1, -37.03, 36.62, -35.06] },
LC: { d: 'M33.1 0L30.8 0L30.8 2.4L28.4 2.4L28.4 9.8L23.7 9.8L23.7 14.6L26 14.6L23.7 17.1L26 17.1L21.3 22L16.6 22L18.9 22L18.9 24.4L21.3 24.4L14.2 24.4L14.2 29.3L16.6 29.3L16.6 31.7L11.8 31.7L11.8 34.2L14.2 36.6L11.8 36.6L7.1 41.5L7.1 43.9L4.7 43.9L4.7 48.8L2.4 48.8L2.4 53.7L0 56.1L0 61L2.4 61L2.4 63.4L4.7 63.4L4.7 65.9L2.4 65.9L2.4 68.3L4.7 70.8L2.4 70.8L2.4 73.2L0 73.2L0 75.6L2.4 75.6L2.4 80.5L4.7 80.5L7.1 82.9L7.1 85.4L9.5 85.4L11.8 87.8L18.9 87.8L18.9 90.3L21.3 90.3L21.3 92.7L28.4 92.7L28.4 95.1L30.8 95.1L28.4 97.6L30.8 97.6L30.8 100L30.8 97.6L33.1 97.6L30.8 95.1L33.1 92.7L33.1 90.3L35.5 90.3L35.5 82.9L37.9 82.9L37.9 85.4L40.2 85.4L40.2 82.9L42.6 82.9L42.6 73.2L45 73.2L45 70.8L42.6 70.8L42.6 68.3L42.6 70.8L45 70.8L45 68.3L47.4 68.3L47.4 65.9L45 65.9L47.4 65.9L45 65.9L45 63.4L47.4 63.4L45 63.4L45 61L47.4 61L45 61L47.4 61L45 61L47.4 58.6L42.6 58.6L45 58.6L42.6 56.1L47.4 56.1L47.4 51.2L45 51.2L45 48.8L47.4 48.8L45 48.8L45 43.9L47.4 43.9L47.4 39L49.7 39L47.4 39L47.4 34.2L49.7 34.2L47.4 31.7L49.7 31.7L45 26.8L45 24.4L47.4 24.4L45 24.4L45 19.5L42.6 19.5L45 19.5L42.6 17.1L45 17.1L45 14.6L47.4 14.6L45 14.6L45 12.2L42.6 12.2L42.6 9.8L42.6 12.2L40.2 12.2L40.2 7.3L37.9 7.3L35.5 4.9L37.9 4.9L37.9 2.4L35.5 2.4L35.5 0L33.1 0Z', w: 49.7, h: 100, b: [-61.08, -14.25, -60.87, -13.83] },
LI: { d: 'M18.5 0L18.5 4.6L15.5 13.7L9.3 22.8L9.3 27.3L6.2 31.9L6.2 50.1L12.4 59.1L12.4 63.7L15.5 68.2L15.5 81.8L12.4 81.8L6.2 90.9L0 90.9L0 95.5L3.1 100L3.1 95.5L3.1 100L6.2 100L6.2 95.5L9.3 100L9.3 95.5L18.5 95.5L21.6 90.9L21.6 95.5L24.7 95.5L24.7 100L34 100L37.1 95.5L43.3 95.5L43.3 86.4L49.5 86.4L49.5 81.8L52.5 81.8L52.5 77.3L49.5 77.3L49.5 72.8L46.4 72.8L46.4 68.2L49.5 68.2L49.5 63.7L52.5 63.7L46.4 63.7L49.5 63.7L46.4 63.7L46.4 59.1L49.5 59.1L46.4 59.1L49.5 59.1L46.4 59.1L49.5 54.6L40.2 54.6L40.2 50.1L37.1 50.1L37.1 45.5L27.8 45.5L30.9 45.5L30.9 36.4L34 36.4L34 27.3L30.9 27.3L30.9 22.8L24.7 22.8L24.7 18.2L27.8 18.2L27.8 13.7L30.9 13.7L27.8 9.1L24.7 9.1L24.7 4.6L21.6 4.6L21.6 0L18.5 0Z', w: 52.5, h: 100, b: [9.47, -53.78, 9.64, -53.45] },
LK: { d: 'M13.1 0L10.9 1L13.6 1.5L19.2 6.7L15.4 4.6L13.4 1.8L11.1 2.1L10.9 0.3L5.3 1.5L6.1 3.6L7.6 3.6L7.8 4.6L10.9 5.9L9.1 4.1L9.9 2.8L10.6 3.3L12.1 2.3L10.4 3.8L10.9 4.6L10.6 3.3L9.3 4.4L11.6 5.6L11.6 4.6L14.4 5.6L17.7 8.7L14.7 9.7L13.4 9.2L14.7 9.2L13.9 8.7L14.7 8.5L13.1 7.4L8.8 5.9L12.4 9L8.8 11.3L9.1 12.8L10.9 13.8L9.3 20.5L7.8 21L6.3 23.6L5.3 22.8L6.6 28.4L5.6 31.5L6.1 32.8L4.3 33L3.5 35.8L3.8 39.7L2.3 40.9L3.3 43L2.3 45.3L3.3 47.3L1.5 47.3L1.5 45.3L0.8 45L2 40.2L0.8 40.7L2.3 37.9L0 41.2L0 45.5L3 57L2.3 57L2.3 59.3L4 65.4L3.3 66.9L4.3 68L4 69.7L3.3 66.9L3 68.2L4.8 72.8L3.5 73.6L4 77.1L6.3 83L6.8 82.5L6.8 86.3L7.8 86.8L8.3 91.1L9.9 94.4L13.6 97.7L22.5 100L28 96.7L36.1 94.7L37.6 93.1L37.6 93.9L41.2 92.9L50.5 85.5L53.8 78.4L54.1 76.4L53.3 76.1L54.3 75.6L54.3 72.5L55.1 72.3L54.6 67.7L54.3 69.2L53.6 68.5L54.6 67.7L54.8 63.9L50.8 53.4L47.5 50.9L47 48.3L45.7 49.6L47 47.1L46 47.3L44.5 45.3L41.7 34L39.9 33.8L40.2 35.3L38.4 35.6L37.9 34.8L37.4 35.6L37.1 33.5L38.1 34L37.6 32.2L39.2 33L38.7 30.2L37.4 28.2L36.4 28.4L37.4 28.2L34.9 24.6L33.1 24.1L30.3 18.4L29.6 18.7L28 13.8L16.7 4.4L13.1 0Z', w: 55.1, h: 100, b: [79.7, -9.88, 81.88, -5.92] },
LR: { d: 'M71.7 23.7L69.3 22L69.8 20.3L66.7 20.3L66 24.8L62.7 28.4L62.9 30.6L61.3 30.8L61 29.9L56.5 32.5L56.3 30.6L54.4 29.6L54.4 27.7L52.3 27.9L52.3 26.8L50.9 26.5L50.1 27.7L49.2 26.8L47.8 28.2L50.6 22.5L50.9 19.1L48.7 16L49.4 11.7L48.3 12.2L48.3 8.8L46.8 8.6L47.5 6.9L46.8 5.5L47.8 4.5L45.4 3.1L43.5 3.6L44.5 2.4L43.5 1.2L42.1 2.6L41.4 0L39.3 1.7L37.4 1.2L35 2.9L34.1 2.9L34.1 1L32.4 0.5L30.7 1.7L28.9 1.4L28.4 8.4L27.2 9.6L23.7 9.6L21.1 12.2L21.1 18.6L18.9 19.4L15.4 24.1L9.5 27.7L7.6 30.8L4.5 32L3.5 33.4L4 35.1L2.8 35.3L1.9 38.4L0 38.9L3.5 42.7L6.1 41.8L7.6 43.7L7.1 44.9L6.1 44.6L3.1 42.2L3.1 43.9L12.8 48.2L16.6 51.8L17.3 51.3L16.3 52.5L17 53.7L16.1 53.4L19.2 55.3L26.7 57L27 58.4L30 59.6L40.7 70.3L45.2 73.2L45.2 74.4L52 80.1L52 81L58 84.1L58.2 85L62.7 86.5L65.5 89.1L69.5 90.3L76.9 94.8L84.2 96.2L86.8 97.4L87.8 99.1L89.4 99.5L89.2 98.6L89.2 99.5L93.9 99.8L93 99.1L93.4 93.6L92.2 86.7L93.9 86.5L93.2 82.4L95.3 81L95.1 77.9L96.5 77.4L96.7 78.4L97.7 76.7L97.5 75.3L97 76L95.8 74.6L97.5 72.2L97 70.8L97.7 70.8L96 67.7L96.5 64.4L95.3 64.1L94.9 65.3L94.1 64.1L93.4 64.9L92.7 64.1L93.4 63.9L92.7 63.2L90.8 63L90.8 62L90.4 63.2L88.2 62L87.8 58.7L86.1 58.7L87 56.1L85.2 53.9L82.1 53.2L78.5 54.1L74.7 51.8L73.3 52.5L72.4 48.9L71.4 50.6L70.3 49.2L68.6 48.9L69.3 47.2L70.3 47.2L70 46.3L72.1 45.6L74.7 42.7L75 37.7L76.2 37.2L75.5 34.9L76.2 32.7L74 31.3L72.9 24.4L71.7 23.7Z', w: 97.7, h: 100, b: [-11.5, -8.58, -7.37, -4.35] },
LS: { d: 'M66.9 0L64.5 1.9L61.6 1.4L61.2 2.3L56.7 2.3L55.5 5.6L53.9 6.5L53.1 5.6L52.7 6.5L49.8 5.6L48.2 6.5L47.8 5.6L43.7 10.2L43.7 11.6L42.4 11.6L42.4 13L41.2 13.5L41.6 14.4L39.6 14.4L37.6 13L37.1 14.4L35.9 14.4L35.9 16.3L34.3 15.4L33.9 16.3L33.5 15.4L33.1 16.3L30.2 15.4L31 16.8L29 17.7L29 20.5L27.3 20L27.8 21.4L26.9 22.4L25.7 21.9L27.3 23.8L25.7 23.8L26.1 25.6L25.3 25.2L24.1 26.6L24.9 27.5L22 28.9L21.6 30.3L20.4 30.3L22 31.7L19.6 33.1L19.6 34L18 34L18.8 35L17.1 35.9L17.6 37.3L16.3 36.9L17.1 38.7L15.5 39.2L14.3 42.9L12.7 42.5L11.8 43.4L12.2 44.8L10.2 44.8L10.2 45.8L8.6 45.3L8.2 46.7L5.7 46.2L2.9 49L2 48.1L2 50L1.2 48.6L0.4 49L0 50.9L3.7 54.2L8.6 67.4L11.8 69.8L13.1 74L16.3 73.5L15.5 75.9L14.7 75.9L14.7 80.1L15.9 81.6L15.1 83L18.8 81.6L18.8 83.9L20 85.3L21.2 84.9L22.4 85.8L22.4 87.7L24.5 88.7L24.5 90.5L27.3 92L29.8 95.8L35.9 95.8L38 97.7L44.1 97.7L44.9 99.1L44.9 95.3L46.5 94.8L46.9 92L47.8 92L46.9 88.7L48.2 87.2L51 86.8L50.6 85.8L51.4 85.3L51 82.5L49 81.6L49.4 79.7L51 78.7L53.1 79.2L56.7 73.5L59.6 74.5L62.4 72.6L67.8 73.5L69.8 72.1L74.3 71.6L80.8 68.3L81.2 66.9L82.4 66.9L84.1 65L85.3 65.5L86.1 63.6L87.8 63.6L87.8 61.3L85.3 59.9L86.5 58.9L86.1 56.1L87.3 54.7L87.8 51.9L93.1 49.5L93.1 47.2L94.3 47.2L93.1 42.9L97.1 41.5L97.6 39.7L99.6 37.8L99.2 35.9L100 35.9L98.4 30.3L95.1 28.9L95.9 26.6L95.1 24.7L93.1 23.3L91.4 24.2L89.4 20.5L86.1 19.1L86.5 18.2L84.1 17.2L84.5 16.3L83.3 16.3L82 14.4L80.4 16.3L80.4 14.9L78.8 14L79.2 11.6L78 11.6L79.2 10.2L78 8.8L76.3 9.8L75.9 8.8L73.5 9.3L73.5 6.5L69.4 5.1L69 1.9L66.9 0Z', w: 100, h: 99.1, b: [27.01, 29.83, 29.46, 32.26] },
LT: { d: 'M96.4 23.8L92.2 23.2L86.2 15.2L80.3 11.5L80 9.3L78.6 10L77.5 8.7L76.9 9.6L73.6 9.6L72.5 8.1L69.9 8.4L66.7 0L65.6 0.3L65.8 1.2L61.8 2.2L60.4 5.3L58.2 5.9L55.8 4.7L54.1 6.2L51.8 5.3L51.3 3.7L47 4.1L47.2 2.5L43.4 3.7L40.4 2.2L36.6 2.5L36.8 3.7L35.2 4.7L33.7 1.2L32 2.5L28 3.1L24 1.2L22.3 1.9L16.8 0.6L16.1 2.2L13.3 2.5L11.7 4.4L9.7 3.7L7.8 4.7L7.1 6.2L4.7 7.2L3.1 9L2.8 11.5L0.2 11.8L0.2 19.8L3.5 29.6L2.4 34.2L3.8 33.6L3.6 36.9L6 35.7L7.6 38.7L10.5 39L14.7 42.1L16.9 41.8L17.8 43.9L19.2 42.7L24.7 43.3L26.6 42.4L27.6 45.7L28.2 44.8L29.4 46.9L31.1 47.5L31.8 49.9L29.2 52.6L29.5 55L28.2 56.8L28.5 60.9L30.1 63.6L31.8 62.1L34.7 63.9L34.5 65.1L36.1 64.8L37.7 66.6L39.7 66.9L42.8 72.2L42.1 75.1L44.2 76.6L46.8 76.3L47 77.2L49.4 75.1L50.6 76.6L54.6 75.1L56.6 77.2L58.7 77.2L59.9 75.1L62.5 73.6L63.2 75.4L66 73.3L64.2 71L65.1 69.8L67.9 69.2L69.4 70.4L71.7 68L71.8 66.6L74.3 66.6L76.9 65.1L78.4 67.4L77 68.9L77.5 69.8L81.9 69.8L81.2 68L81.9 67.4L80.1 64.8L77.7 65.1L77.4 64.2L79.1 62.1L78.6 60.6L80 58.2L81.3 57.4L81 49.9L83.4 46.3L86.5 46.3L87.6 44.8L88.9 44.8L90.2 42.4L89.5 41.2L91 39.9L93.6 40.6L94.1 39.6L97.2 39.6L98.1 36.9L100 35.7L97.2 34.2L93.1 34.2L95 32.7L94.5 29L96.4 27.2L96.4 23.8Z', w: 100, h: 77.2, b: [21.05, -68.71, 26.84, -64.24] },
LU: { d: 'M7.1 86.4L6.2 87.8L8.8 87.8L9.7 89.1L8 89.1L10.6 93.2L18.6 93.2L18.6 94.6L20.3 94.6L20.3 97.3L22.1 98.6L21.2 100L23 98.6L23 100L24.7 98.6L27.4 100L28.3 97.3L31.8 100L31.8 97.3L33.6 97.3L34.5 94.6L37.1 94.6L36.2 93.2L37.1 91.8L46 91.8L48.6 95.9L49.5 94.6L49.5 95.9L54.8 97.3L55.7 98.6L54.8 87.8L56.6 85L54.8 82.3L56.6 82.3L55.7 80.9L58.3 79.6L61 75.5L61.9 72.8L61 70L66.3 64.6L68.1 64.6L67.2 61.8L68.9 63.2L67.2 61.8L67.2 57.7L68.9 57.7L68.1 53.6L69.8 50.9L68.1 50.9L68.1 52.2L68.1 50.9L65.4 49.5L61 50.9L61 49.5L56.6 48.1L55.7 45.4L51.3 46.8L50.4 42.7L46 41.3L45.1 38.5L43.3 38.5L42.4 35.8L43.3 33L40.7 31.7L39.8 28.9L38.9 31.7L38 27.5L35.4 24.8L36.2 23.4L34.5 23.4L34.5 22L36.2 22L32.7 16.5L34.5 15.2L33.6 12.4L35.4 6.9L32.7 5.5L33.6 2.8L31.8 1.4L30.1 1.4L28.3 4.1L25.6 2.8L25.6 0L20.3 1.4L19.4 6.9L15 8.3L13.3 13.8L12.4 15.2L11.5 13.8L9.7 16.5L11.5 17.9L10.6 20.7L7.1 23.4L8.8 27.5L6.2 28.9L6.2 30.3L3.5 30.3L2.7 31.7L3.5 33L0 37.2L0.9 39.9L3.5 41.3L3.5 42.7L0.9 42.7L1.8 42.7L0.9 44L1.8 45.4L0 46.8L0.9 52.2L1.8 53.6L4.4 52.2L8 59.1L8 63.2L11.5 61.8L13.3 64.6L10.6 67.3L11.5 70L15 70L14.1 71.4L15 74.1L13.3 74.1L12.4 78.2L9.7 79.6L9.7 80.9L11.5 80.9L11.5 83.7L9.7 83.7L8.8 86.4L7.1 86.4Z', w: 69.8, h: 100, b: [5.74, -58.19, 6.53, -57.06] },
LV: { d: 'M98.8 48.7L100 45.7L98.9 43.5L99.3 41.7L97.9 40L98.6 38.5L97.4 38.5L96.8 36L95.3 34.2L96.4 32.2L95.3 32.5L95 31L92 32.2L93.5 27.9L92.7 27.9L93.5 25.9L92.7 25.4L94.2 24.4L94.9 20.3L92.3 18.6L92.2 17.8L90.1 17.3L90.6 14.2L87.5 14.7L81.6 11.9L79.9 13.7L79.4 12.9L78.5 13.4L77.7 15L77.3 14.2L76.9 15L76.1 14.7L75.7 13.2L73 12.9L71.7 10.4L71.9 9.6L69.5 8.3L70 6.2L66.7 6L65.9 4.4L64.8 4.9L63.4 3.9L63.5 3.1L62.2 3.1L61.5 2.3L61.9 2.1L60.4 1.6L59.6 0.3L59 0.8L59.6 2.6L59 2.6L58.2 0L53.1 3.1L52.3 2.6L51.2 3.6L49.8 3.4L48 4.2L47.3 6L46.5 5.4L47.3 21.4L45.4 23.9L45.7 24.4L45.3 23.9L43.7 25.7L42.1 26.2L42.5 26.9L41.3 27.9L36.5 29L34.5 26.9L33 26.9L31.6 25.9L29.7 18.6L27.2 17.5L26 15.5L23 13.2L22 10.6L22.6 8.5L21.9 8.5L14.7 12.4L10 13.4L6.1 20.6L5.6 27.7L1.1 32.2L0 44.7L1.2 51.1L3.3 50.9L3.6 48.9L4.8 47.4L6.7 46.7L7.3 45.5L7.8 45.7L8.8 44.7L10.5 45.2L11.7 43.7L13.9 43.5L14.4 42.2L18.8 43.2L20.2 42.7L23.4 44.2L26.5 43.7L27.1 42.7L27.9 42.7L28.1 44.2L29.2 45.5L30.4 44.7L30.3 43.7L33.3 43.5L35.2 44.7L38.7 43.7L38.5 45L38.8 44.5L42 44.7L42.4 46L44.2 46.7L45.5 45.5L47.5 46.4L49.2 46L50.3 43.5L53.5 42.7L53.4 42L54.2 41.7L55 45.2L56.3 46.2L56.8 48.4L58.9 48.2L59.7 49.4L60.7 48.9L62.3 49.4L62.9 48.7L63.7 49.7L64.8 49.2L65.1 50.9L66.7 51.4L67.3 52.9L69.7 53.8L70.6 55.6L72.2 56.5L72.9 58.8L74.6 60.2L75.5 60L77.4 60.9L78.1 60L80.1 60.7L81.2 60L81.8 58L84.5 56.5L85.3 56.5L86.4 58L88.3 57.5L91.2 58.3L91.9 54.6L93.9 53.4L94.1 51.9L95.5 51.6L96.6 49.7L97.4 49.9L98.8 48.7Z', w: 100, h: 60.9, b: [20.97, -71.74, 28.24, -67.31] },
LY: { d: 'M16.6 69.8L26.3 72.2L27.5 72.9L30.5 75.8L41.9 70.2L70.4 84.8L92.7 97.1L92.7 93.8L99 93.8L99 29.1L98.2 27.1L98.5 26.5L97.1 22.4L97.7 21.2L98.6 20.1L99.2 17.9L98.2 15.2L98.2 13.3L99.6 12.2L99.6 11.7L100 11.4L99.2 10L99.3 9.3L97.5 8.7L96.3 9L93.2 8.7L92.6 8.4L92.8 8.1L92.5 8L90.9 7.5L89 7.5L88.3 7.2L88.3 7.7L87.7 6.7L86.9 6.5L86.9 5.6L87.2 5.9L87.4 5.4L87 4.9L87.1 4L85.9 3.9L82.5 2.3L81.4 2.3L81 1.7L79.5 2.1L77.7 1.8L76.2 2.9L74.2 3L70.9 4.7L67.5 7.9L67.8 8.1L67.6 7.9L67.6 8.4L66.9 9.1L66.8 10.8L67.4 13.3L68.4 14.8L68 16.7L66.8 18.8L65 20.5L62.7 21.6L60.9 21.6L58.9 20.7L58.3 20.1L58.4 19.9L57.2 19.3L56 18L54.1 17.4L53.8 16.8L51.6 16.3L50.8 15.7L46.9 14.7L42.7 14.4L40.4 13.4L38.8 11.5L37.9 9.2L37.9 7.5L37.2 6.2L36.8 5.9L32.2 5L30.8 3.9L30.9 3.6L28 2.8L26.7 2.9L25.4 2L24 2L24.2 1.9L22.9 2.6L21.4 2.9L18.5 2.6L15.7 0.8L14.3 0.6L13.8 0L13.3 3.9L14 4.8L14 5.4L9.8 7.6L8.9 8.8L7.9 9.1L7.9 9.9L7.2 10.8L6.3 10.8L5.6 11.3L4.7 12.7L5.7 15.6L5.8 17.1L5.5 18.1L3.4 20.9L0 22.4L0.1 23.3L1 24.4L2.7 28.6L3.2 32.1L2.8 36L3.7 39L3.4 40.5L3 41.1L2.7 41.1L3.1 41.4L2.7 41.9L2.5 43.4L3 45.8L3.4 46.3L3.2 48.1L3 48.7L0.7 49.7L0.1 51L4.1 56.9L4.1 59.7L5.2 61.8L6.2 62.7L8.4 62.4L8.8 62.8L9.3 62.4L12.9 65L13.8 64.6L13.3 65.9L14.2 66.9L14.7 68.1L15.2 68.2L15.4 69.1L16.1 69.7L16.6 69.8Z', w: 100, h: 97.1, b: [9.39, -35.2, 25.15, -19.89] },
MA: { d: 'M90.1 8.4L88.3 7.8L87.3 8.5L86.4 8.4L84.8 7.2L85.7 8.2L84.6 8.1L84.1 7.4L84.2 6.7L84.7 7.2L84.3 6.5L83.8 6.5L84 4.9L83.6 5L82.9 6.5L80.9 7.4L78.8 7.1L77.6 6.4L77 7.3L76.1 7.2L75.8 6.7L71.8 7.8L69.2 7.1L65.2 3.7L64.9 2.3L64.5 2.4L64.3 2L64.3 0.5L63.8 0L63.2 0.1L62.4 0.9L61 1L60.8 1.4L59.6 1.2L56.6 10.6L52.2 19L50.5 20.4L47.8 22.1L47.5 21.9L46.3 23L45.3 23.1L44.2 23.9L40 25.5L38.6 26.8L38 26.7L35.7 30L31.9 33.6L32.4 34.2L31.9 35.2L32.3 35.9L32.1 37.2L31.5 38.6L28.7 41.8L28.7 42.6L27.4 44.6L27.8 45.3L27.4 47L27.7 50.2L27 52L27.9 52.2L28.9 54L29.3 54.1L28.9 56.9L27.8 59.4L26.2 61.1L24.3 64.5L21.5 67.5L17.3 69.8L13.7 73.9L8.8 76.1L2.1 77.2L1.1 79L0 79.9L37 79.9L37 70.2L38.9 69.3L39.8 68.2L44.2 65L45.4 64.6L45.7 63.9L47.9 63.8L49.7 62.5L52.7 63.1L53.1 62.7L54.6 62.6L55.3 62.1L58.8 62.1L60.6 61.6L61.2 61.8L61 62.7L61.5 62.4L62.1 62.9L62.5 62.8L64.3 60.4L64.8 58.9L67.5 56.8L70.4 55.3L72.6 53.1L78.4 51.4L78.1 49.9L79 49.1L79.1 47.9L78.6 47.8L78.3 47L78.4 47.6L78 47.6L77.8 46.8L77.2 47.5L77.1 46.3L77.3 45.1L78.1 44.8L78.1 42.4L85 40.8L84.1 38.7L84.3 37.7L86.9 37.8L88.1 37.3L90.8 37.5L91.8 37.1L95.1 38L98.4 38.1L98.8 37.8L98.5 37.2L97.5 37.3L98 36.9L97.9 35.6L100 33.8L96.7 31.5L95.6 29.5L96.1 29.3L96.1 28.3L94.5 26.5L95.2 23.1L94.7 22.4L93.9 22.2L94.5 21.5L94.2 20.5L94.7 18.2L94.2 16.1L93.6 15.5L94.3 14.3L93 13.2L93.9 11.8L92.8 11.3L92 10.5L92 9.9L91.5 10.1L90.3 9.2L90.1 8.4Z', w: 100, h: 79.9, b: [-13.17, -38.53, -1, -28.81] },
MC: { d: 'M24.1 100L24.1 66.7L48.2 66.7L24.1 66.7L24.1 33.3L48.2 33.3L48.2 0L72.3 0L48.2 0L48.2 33.3L0 33.3L0 66.7L24.1 66.7L24.1 100Z', w: 72.3, h: 100, b: [7.41, -48.75, 7.44, -48.71] },
MD: { d: 'M35.9 100L37.9 97.4L40.6 99.4L42.9 99L42.7 91.6L49 88.4L48.3 84.2L53.3 82.9L54 81.2L52.6 79.6L55.3 76.3L52.6 74.4L53.7 72.1L52.2 67.8L54.4 67.8L53.3 66.9L59.2 64.6L58.5 70.5L61 69.5L61.2 66.5L62.6 68.8L63.9 66.5L67.3 71.4L69.1 71.1L68.7 68.5L70.2 68.8L70.2 67.5L73.8 71.4L74.7 69.8L78.6 70.5L79.9 69.5L76.8 68.5L76.1 66.2L74.7 66.9L73.6 65.2L75.7 63.6L74.7 56.3L75.9 56L74.3 56L73.6 53.7L70.7 54.7L70 52.4L66.6 52L68 48.4L67.5 46.7L65.7 47.4L65 45.7L66.8 45.4L66.8 37.7L64.8 38.1L63.5 40.4L61 35.1L59.6 36.1L59.2 34.7L57.4 35.1L58 32.7L56.5 31.7L58.7 29.7L58.3 26L60.1 23.4L58.5 23.4L60.1 20.3L58.3 20.7L57.8 17L55.6 17.3L55.3 18.6L53.1 17.3L52.2 18L50.1 15.6L50.1 12.2L46.7 12.6L44.3 10.5L42.5 11.5L42.5 14.3L41.6 14.3L40.6 10.5L37.9 11.9L39.5 9.9L39.1 8.2L35.9 9.9L35.2 7.8L33.2 8.8L33.2 6.1L29.8 5.4L25.7 1L22.8 1.7L22.1 0L19 1.4L18.7 2.7L16.3 1.4L15.1 4.1L11.7 4.1L10.6 2L8.8 2L9.7 4.1L8.6 4.4L6.1 3.7L5.9 2.4L2 2.7L4.7 6.5L1.4 4.4L0 7.5L4.5 8.2L8.4 11.2L7.7 12.2L9.3 11.9L10.6 16.3L12.4 17L11.7 19.7L13.1 19.7L12.9 21.7L14.2 22L13.3 22.7L15.4 25L14.2 26L15.1 28.1L18.3 30.7L19.2 33.7L21.5 34.4L22.6 38.7L21.7 39.1L24.2 40.7L25.1 40.1L26.9 44.4L26.2 45.4L33.4 50.7L33.6 56L36.8 61.6L35.9 66.5L37 69.1L35.2 71.4L35.9 72.7L33.6 74.7L34.3 77.6L33.2 80.2L33.6 89.7L35 91.3L35 94.5L33.2 95.8L34.8 96.5L34.6 98.7L35.9 100Z', w: 79.9, h: 100, b: [26.62, -55.59, 30.16, -51.17] },
ME: { d: 'M71.7 59.5L72.2 57.7L71.3 56.5L73 53.6L72.2 52.4L70.4 52.4L70.4 51.2L69.1 50.6L69.1 47.1L72.6 46.5L73 47.7L74.8 48.3L74.8 47.1L76.1 48.3L78.7 48.3L80 46.5L79.6 44.1L81.3 42.9L83.5 43.5L83.5 38.8L80.9 38.2L80.4 37L78.2 37L77.8 35.8L76.1 35.8L75.2 34.6L74.8 35.2L74.8 34L73.5 35.8L72.6 35.2L70.4 31.7L67.8 30.5L66.9 26.9L64.8 28.1L62.2 26.9L61.3 28.1L61.3 26.3L59.1 26.3L58.3 23.3L56.9 23.9L52.6 22.1L51.7 19.7L48.3 18.5L48.3 15L44.8 13.2L44.3 10.2L42.6 10.2L34.3 4.8L34.3 1.8L31.3 1.2L30.4 2.4L26.5 3.6L26.5 1.8L24.3 0L23.9 1.8L20.9 3.6L22.6 3.6L23 6.6L25.2 6.6L26.5 12L28.3 13.2L28.3 15.6L26.1 16.2L24.8 18.5L23.9 18L24.3 16.8L22.6 16.2L23 13.8L21.3 12L19.1 12L17.8 12.6L18.3 14.4L11.3 18L12.6 19.7L10.4 20.9L9.6 23.3L10 31.1L4.3 31.7L2.6 34L2.2 37.6L3.5 40L1.7 41.8L2.2 43.5L0.9 44.7L1.7 48.3L4.3 50L6.1 50L5.2 51.2L6.1 54.8L3.9 56L5.2 57.1L4.8 58.9L3 57.7L2.6 58.9L0.4 59.5L0.9 62.4L0 64.2L1.7 64.8L4.3 67.8L3.5 65.4L7.4 67.2L10.4 66L11.3 64.2L9.6 63L10.9 63L11.3 61.9L11.7 64.2L14.8 63.6L14.8 67.8L13.9 65.4L11.3 64.2L10.9 66.6L12.2 67.8L12.2 69.5L11.3 68.3L10.4 68.9L7.4 67.2L5.2 67.2L5.2 68.3L7.8 71.3L10 71.3L10 69.5L11.3 70.1L12.2 73.6L14.8 76.6L19.6 76L21.7 80.7L22.2 80.1L23.5 82.4L25.2 83L25.2 84.8L27.4 84.2L27.4 85.4L28.7 86L27.8 87.7L31.3 90.6L30.9 94.2L32.2 96.5L33.5 96.5L34.8 98.2L37.8 96.5L35.2 97.7L40.4 100L41.3 98.8L39.6 97.7L40.9 97.1L40 94.7L41.7 94.7L40.9 89.5L42.6 85.4L37.4 81.3L41.7 76L43 70.7L44.3 69.5L44.8 70.1L49.6 61.9L51.7 60.7L51.3 58.3L53.5 55.4L54.8 55.4L55.2 53.6L57.8 54.8L57.4 56.5L58.7 58.3L57.4 58.3L56.9 60.7L57.8 63L60 64.8L62.6 64.2L63 63L63.9 64.2L65.2 61.9L68.7 62.4L68.7 60.7L71.7 59.5Z', w: 83.5, h: 100, b: [18.43, -48.49, 20.35, -46.19] },
MG: { d: 'M41.7 0L41 0.6L41.5 1.2L40.5 1.1L41.3 1.4L41 2.3L39.7 2.8L39.5 3.9L38 3.5L39.2 4.2L38.9 5.1L39.7 6.7L38.6 8.2L38.6 10.1L37.5 10.8L36.3 10.1L36.8 11.2L35.2 11.4L34.8 13.3L33.4 11.2L32.3 11.6L32.1 13L33.2 14.1L32.5 14.6L33.3 14.9L33.3 16.4L33 17.3L33 15.5L32.3 15.2L32.5 16.4L31.5 16.2L30.9 17.8L31.2 18.9L32.8 18.2L32.8 20.7L32.9 19L32.4 19.7L31.6 18.6L29 22.5L29.7 20.4L29.3 19.3L28.1 20.7L28.7 21L26.4 23.1L27.6 24.9L25.3 26L26.6 24.2L25.7 23.1L21.8 25.9L21.3 27.6L23.5 29L23.4 29.7L21.6 28.9L20.9 26.8L20 26.8L19.2 27.5L20 27.3L19.7 28.1L18.5 27.1L17.6 27.7L16.9 27.2L16.7 29.4L15.9 29.4L16.1 28.5L14.7 28.7L15.2 29.1L14.3 29.9L14 28.4L11.3 30.8L8.6 30.1L8.4 33.9L4.9 39.7L5.1 41.3L5.9 41.3L5.3 41.2L5.7 46.2L7.2 48.5L7 51.1L8.6 53.9L8.1 56.4L8.7 56.5L8.8 57.8L4.7 64.2L4 67L1.9 67.6L1.8 70.3L0.4 72L0.1 73.9L0.6 74.5L0 74.7L1 78.9L2.6 80.7L2.8 82.8L3.8 83.7L3.9 84.5L2.9 85.2L3.1 90.2L4.8 92.3L5.5 95.3L7.9 97.4L11 97.9L13.5 100L16.4 99.5L21.3 96.8L24.3 96.7L24 96.2L24.5 96.6L27.1 94.8L30.3 86L32.3 76.1L42.9 44.5L43.5 41.2L43 37.8L44 35.4L45.7 34.9L44.9 33.9L46 32.2L45.9 30.4L44.6 29.2L45 28.1L44.2 25.5L46.1 24.8L47 28L48 28.8L49.2 27.6L50 25L50.2 23.6L48.1 18.7L47.8 13L46.4 7.7L44.9 5.6L44.3 6.2L43.8 3.4L43.2 3.3L43.7 3.1L42.5 2L42.4 2.5L41.3 2.5L41.9 2.2L41.6 1.4L42.5 1.8L41.7 0Z', w: 50.2, h: 100, b: [43.22, 12.04, 50.49, 26.51] },
MH: { d: 'M49.7 74.8L49.6 74.4L49.5 74.6L49.9 75L51.2 75.5L52.9 75.2L51.3 75.5L49.7 74.8ZM55.1 76.1L54.8 75.7L55.2 76.2L55.7 76.1L56.5 76.5L55.7 76L55.1 76.1ZM29 70.1L29.2 70.2L28.9 70.8L29 71L29.3 70.4L29 70.1ZM27.1 73.3L27.8 73.4L27.1 73.3ZM26.7 99.9L27.2 99.5L26.8 99.9L26.4 99.9L26.7 99.9ZM56.7 75.8L57.1 75.4L56.5 76.1L56.7 75.8ZM9 34.9L8.3 35L9 34.9ZM17.1 59.1L17 58.9L16.9 59.2L17.1 59.1ZM37.9 34L37.6 34.2L37.9 34ZM25.4 72.4L25.2 72.2L25.2 71.6L25.1 71.9L25.4 72.4ZM0.2 44.8L0 44.6L0.2 44.8ZM35.9 86.8L35.7 87L35.6 88L35.2 87.8L35.3 88L35.6 88L35.9 86.8ZM21.1 89.6L20.9 89.6L20.9 90L20.7 89.9L20.8 90.1L21.1 89.6ZM29.6 0.2L29.6 0L29.6 0.2L29 0.6L29.6 0.2Z', w: 57.1, h: 100, b: [165.99, -14.78, 171.82, -4.57] },
MK: { d: 'M20.5 78.8L26.4 78.8L28.3 77.3L32.6 78.3L35.7 77.3L37.2 75.2L41.9 75.7L42.6 77.3L44.6 77.8L45.7 76.2L47.3 76.2L47.7 74.2L51.9 74.7L52.7 71.6L53.9 71.6L54.3 70.1L56.6 69.1L56.2 67.5L57.4 65.5L61.2 64.4L62.4 62.9L65.1 64.9L68.2 62.4L72.5 63.4L72.9 64.9L73.6 63.9L76 63.9L76 64.9L83.3 64.9L86 61.3L87.6 63.9L88.8 63.4L89.9 54.1L97.7 53.1L96.9 49.5L98.1 47.9L97.3 47.4L97.7 41.2L96.9 38.1L100 35L99.6 31.8L97.3 31.3L96.5 27.2L95 25.6L93.4 17.8L91.9 18.3L91.1 16.7L88.8 17.3L88 15.7L86.4 16.2L84.1 14.1L83.7 14.7L83.7 13.6L82.2 13.6L81.4 12L80.2 12L79.5 8.9L78.3 8.9L77.5 6.3L74.8 3.1L72.1 2.1L71.7 0L70.2 0L69.4 1.6L67.4 1.6L66.7 3.1L65.5 2.6L63.6 3.7L60.9 3.7L58.9 1.6L57.4 1.6L57 3.7L53.9 2.1L53.1 4.2L51.9 3.7L51.2 5.8L49.2 5.2L48.4 7.3L45.3 5.8L41.5 6.8L41.1 5.2L38.4 4.7L38.4 7.3L37.6 6.3L36 6.3L34.9 8.9L34.1 8.4L34.5 9.4L32.9 12L33.7 13.6L31.8 14.7L29.8 14.7L29.5 11.5L29.1 12L27.5 8.9L25.2 8.4L22.5 11.5L19 12L17.4 14.1L12.8 15.2L11.6 17.8L12.4 21.4L11.6 22L12.8 23.5L10.9 26.6L8.9 26.6L8.1 25.6L7.4 26.6L6.2 26.6L5.8 25.6L4.3 26.1L4.7 30.3L3.1 30.8L2.3 33.4L3.1 34.4L2.7 38.1L4.3 41.2L0.4 42.2L0 44.8L1.2 44.8L2.3 48.4L4.3 49.5L3.1 53.6L1.6 53.6L2.7 59.8L5.4 63.4L5.4 66.5L8.5 66.5L10.9 75.7L14.7 76.2L15.5 74.2L17.1 75.2L19.8 75.2L20.5 78.8Z', w: 100, h: 78.8, b: [20.45, -46.86, 23.03, -44.83] },
ML: { d: 'M44.9 0L34.3 0L40.2 55.1L41.9 56.2L40.8 61.4L17.6 61.4L17.6 60.2L16.9 60.7L17.2 61.8L13.3 62.3L9.1 61.8L8.1 63.9L5 60.5L4.4 60.5L3 61.7L2.4 63.4L2.7 65.2L1.2 66.3L0 66.1L0.6 66.8L0.2 68.3L1.6 69.6L1.3 70.6L1.9 71.3L1 72.6L1.2 73.2L2.2 74.1L2.5 75.1L3 74.4L3.9 74.7L5.4 77.2L5.3 78.7L4.8 79L5.4 80.4L4.5 82.2L6.2 83.2L7.2 81.9L8 81.8L8.8 82.5L8.9 83.6L9.6 83.9L11.5 81.8L15.4 83.1L15.7 82.1L17.6 81.7L17.4 80.2L19.6 80.6L20 81.3L19.7 81.9L20.4 82.3L20.2 83L21 83.4L20.5 85.4L21.4 85.4L22 86.5L23.6 87.1L23.4 87.7L22.3 88.2L21.6 89.7L22.8 89L24 89.4L23.7 92L24.3 93L25.1 92.8L25.3 93.6L25.8 93.5L25.9 94.7L27.9 92.8L29 92.8L29.4 94.1L30.9 94.1L31.6 94.7L32.1 93.4L33.9 93.4L33.9 91.5L35.2 92.1L35.3 91.3L36.3 91L36.8 94.2L37.9 94.4L39.1 92.9L40.8 92.9L41.4 90.4L40.9 88.9L41.9 88.6L42.6 86.8L42.3 84.7L41.4 84.3L42.3 84.3L43.4 83.4L45.5 83.2L46.6 82.4L46.4 82L47 81.2L47.5 81.3L47.2 80.6L47.7 80L47 78.7L48.3 78.9L48.6 78.2L47.8 76.1L50.1 73.9L50.5 74.2L50.2 74.7L51.2 74.7L53.3 76.1L53.3 75.3L54.6 75.2L54.3 72.6L55.7 73.2L56.7 73L56.9 70.5L59.2 68.9L61.4 69.9L62 69.6L62.1 67.8L64 67.7L67.7 65.9L69.8 64.1L71.5 64.1L71.7 64.6L72.6 64.1L75.6 65.1L75.6 64.6L78.4 64.9L80 64.7L82.1 62.8L92.4 62.4L92.5 61.9L95.3 62.3L96.5 60.5L97.7 60L98.3 57.6L99.6 55.8L100 38.3L94.1 39.2L93 38.3L93.9 36.4L93.8 33.8L90.7 32.9L90.4 32.1L88.6 32.6L88.7 31.9L87.6 30.7L85.8 31.4L85 30.9L83.8 28.9L81.2 28L81.3 26.2L80.9 25.4L44.9 0Z', w: 100, h: 94.7, b: [-12.24, -25.83, 4.27, -10.2] },
MM: { d: 'M26.2 1.9L26.3 3.8L23.9 5.3L25.1 8.3L22.4 6.8L15.1 11L15.3 14.1L12.4 17.9L13 20L10.1 26.5L5.9 25.2L6.2 30.4L4.9 30.8L4.6 32.2L5.2 35.2L3.7 37L2.6 35.8L2.6 40.5L1 39.5L0 41.1L2.8 46.1L1.9 42.9L2.9 44.2L3 46.4L3.7 46.9L4.6 44.3L4.1 46.7L4.9 46.2L4.6 47L5.7 47.2L5.8 46.4L6.6 47.8L8 47.9L7.2 48.9L8.2 48.5L7.8 49.5L9.3 50.8L8.4 51.6L6.6 50.9L8.9 53.7L8.7 51.7L9.7 50.9L9.4 53.7L10.6 54.2L10 54.3L12.3 60.5L10.5 69L12.4 67.3L11.2 69.6L12.5 68.2L12 69L12.8 69.6L13.4 68.8L12.9 69.8L13.5 69.2L14.1 70.1L15 68.1L14.9 69.8L15.9 69.1L15.7 70.2L16.6 70.1L17.9 67.4L18.2 68L19.4 66.7L21 66.5L20 64.5L20.9 64.3L20.8 65.8L21.8 66.1L23.7 63.4L23.9 64.4L23.7 60.7L24.4 62.5L25.7 62.9L25.1 63.6L26.2 66.1L28.1 65.8L27.2 68.4L28.1 69.4L28.4 74.7L29.9 78.1L30.1 81.5L30.6 79.3L33.1 85.4L32.6 86.8L33.4 87.1L32.5 87L32.1 88.2L33.3 89.7L32.2 90.2L34 91.1L33.2 91.2L32.7 94L33.5 94.8L31.8 96.2L32.2 100L33.4 96.4L37.8 90.4L35 84.1L35.1 79.7L32.5 77.5L30.4 73.4L32.3 72.1L32.3 68.5L33.7 68.1L34.1 66.7L32.8 67.3L32.2 64L28.2 59.6L27.6 56.6L26.1 55.1L28.3 55.1L27.8 53.1L29.6 48.5L34.5 48.9L34.9 46.9L37.2 46.6L36.8 45.4L40 45.6L40.8 43.4L42.9 42.7L42.1 42.5L43.2 40.4L45.5 38.8L45.1 37.8L43 39.5L40.7 39.5L39.4 38L39.4 36.3L35.3 35.8L36.4 34L36.1 32.6L37.4 31.5L37.2 30.7L33.9 30.2L34 27.7L32.8 26.7L33.9 24.8L27.1 26L28.2 24.8L27.2 21.5L28.4 20.9L28.6 18.6L33 15.3L32.3 13.8L33.2 13.4L33.4 8.6L33 5.7L31.6 5L31.1 5.9L29.3 1.3L27.3 0L26.2 1.9Z', w: 45.5, h: 100, b: [92.17, -29.81, 101.17, -10.02] },
MN: { d: 'M90 11.4L88.5 10.4L87 11.2L86.2 11.1L84.2 9.5L82.6 9.2L79.2 11.4L78.7 12.5L77.8 13L76.8 12.9L73.9 13.9L73.4 13.7L71.6 14.6L70.4 14.2L69.9 14.7L67.8 14.4L66.6 13.7L64.6 13.9L63.9 12.8L62.8 12.3L62.9 10.9L60.4 10.6L59 9.1L57.5 9.2L56.7 8.6L54.7 8.3L51.7 9.1L51 10L48.2 9.7L48.2 9.1L47.3 9.2L46.3 8.7L46 7.8L45.2 7.3L44.7 3.9L42.9 3.3L42.5 3.5L40.2 2.1L37.8 2.1L37.5 1.2L36.1 1.1L34.7 0L34.2 1.5L33 2.1L32.6 3.5L32 3.5L31.3 5.7L32 6.4L31.9 7.3L32.9 8.3L32.9 9.1L32.1 10.5L31.5 11L31.2 10.6L30.6 10.9L30.3 11.6L29.5 11.9L28.8 11.2L27.9 11L27.5 11.3L27.3 10.9L26.9 11.2L25.3 10.4L24.3 11.1L22.8 10.8L22.5 10.4L21.4 10.4L20.7 9.6L20.2 7.8L16.7 7.8L16.3 7.4L16.4 6.8L15.7 6.7L15.2 7.2L14.2 6.3L14.1 7.1L12.4 7.2L11.4 8.4L9.9 8.6L9.2 9.6L8.5 9.5L7.1 10.3L7 10.8L5.8 11L6.2 12L5.1 12.7L4.6 12.4L4.4 13L3.9 13.2L3.6 12.7L3.5 13.3L1.5 13.1L1.4 14L0.2 14.6L0.5 15.5L0 16L1.1 16.8L0.7 17.4L2.6 18.4L2.9 19.3L4.3 20.2L5.7 19.9L6.3 20.9L7.3 20.7L7.4 21.3L8.2 21.7L8.5 23.3L10.3 26L10.4 26.7L9.8 27.8L10.2 29.1L9.1 31.1L9.8 32.8L14 33.6L16 33.4L18 33.8L21.7 36.5L22.6 36.9L23.8 36.7L23.6 37.9L24.2 37.9L25.4 41.2L26.8 42.7L26.9 43.5L29.5 43.2L36.3 44.1L39.1 43.6L41 43.7L43.7 44.4L44.6 45.6L46.5 45.9L48.8 47L50.1 47.3L52.2 47L52.1 47.9L53.7 48.2L55.7 46.8L61.3 44.6L67.1 44.6L70.4 43.3L72.5 40.7L75.2 39.3L75 38.3L73.5 36.4L74.5 33.9L75.2 33.3L76.7 33.3L77.8 34.3L80.4 34.8L81.9 33.9L83.4 31.9L86 31.9L88.3 30.6L89.6 27.9L90.3 27.5L92 27.6L92.2 26.6L92.7 26.5L93.1 26.9L94.9 25.9L96.5 26.1L96.8 25.7L98.3 26.5L99.9 26.2L100 25.2L99.3 23.8L97.5 22.2L97.5 21.6L96.4 21.2L95.7 20.1L93.4 20L92 21.8L90.3 20.6L88.6 20.7L87.6 21.6L86.5 20.5L86.3 19.4L87.2 18.9L87.1 17.6L90 11.4Z', w: 100, h: 48.2, b: [87.73, -61.33, 119.93, -45.8] },
MR: { d: 'M62.1 0L62.1 10.9L37.5 10.9L37.5 31.7L33.2 33.1L29.3 35.8L29 37.2L29.6 40.3L30.1 48.6L0.9 48.6L0 52L0.1 53L0.3 52.3L0.1 52L0.4 51.9L0.5 51.1L0.3 50.8L0.5 50.7L0.5 51.1L0.7 50.8L0.9 49.4L1.2 49L1.5 51L3 53.3L2.9 53.7L4 54.7L4 53.3L4.2 53.4L5.6 56.5L6.4 57.4L6 58.1L6.3 59.2L5.6 59.9L6.1 59.9L6.1 60.7L4.4 63.7L4.7 63.8L4.9 63.6L5 62.9L5.2 62.8L5 63.3L5.3 62.9L5.8 62.9L4.9 63.9L3.9 64.1L4.6 65.1L6 66.2L7.5 71.2L7.8 75L7.5 77.8L4.7 85.3L4.1 88L4.1 89.7L4.6 89.5L4.5 88.7L5.1 88.5L5.2 87.7L6 86.1L6.6 86.2L7.1 85.9L7.5 86.5L8.9 86.2L8.9 86.4L9.4 86.3L10.1 86.6L10.6 86.4L10.6 86.1L11.5 86.2L11.7 85.6L12.1 86L13 85.7L13.5 86L14 85.6L14.5 85.7L14.7 85.5L14.4 85.3L14.6 85L14.9 85.3L15.3 85.3L15.3 84.9L15.7 85L15.7 85.3L16.1 85.3L16.1 85.1L16.6 85.3L17.1 85.1L17.1 85.4L17.3 85.2L18 85.2L17.9 85.4L18.8 85.3L19 85.5L19.5 85.2L20.2 85.3L20.2 85.8L20.6 85.6L20.8 86.3L21.1 86L22.5 87.5L23 87.7L22.8 87.8L23.1 88.1L22.9 88.5L23.1 88.3L23.3 88.7L23.6 88.7L23.8 89.3L24.9 88.8L24.8 89.2L25 89.4L26.5 89L26.4 89.6L26.7 89.3L27.3 89.7L27.3 90.3L27.8 90.7L27.6 91L28.2 91.8L27.9 92L28.5 92.5L28.2 92.6L28.4 93.2L28.7 93L29.4 93.3L29.3 94L29.6 94.2L30.4 94L30.4 94.5L30.7 94.5L30.4 95.1L31.3 95.6L30.8 95.9L31.2 96.3L31.6 96.2L31.6 96.7L32.3 97.2L32.7 97L33.8 97.7L34.4 99L35.7 99.7L36.5 99.5L37 100L37.2 99.6L37.8 99.6L38.1 99.2L38.9 98.7L39 97.6L38.6 97.6L38.9 96.9L38.6 96.5L38.9 96.2L39.3 94.3L39.6 93.7L40.1 93.9L41.2 93.2L41 93L41.8 93L42.2 93.8L44.8 95.9L45.5 97L46.2 96L46.1 95.7L46.8 95.3L46.8 94.5L50.1 94.5L51.7 95.1L56.7 94.5L56.3 93.2L57.2 92.5L57.2 94L85.5 94L86.7 87.7L84.7 86.3L77.6 19.1L90.4 19.1L62.1 0Z', w: 90.4, h: 100, b: [-17.07, -28.42, -4.83, -14.88] },
MT: { d: 'M47.5 27.8L47.5 24.7L45 24.7L45 27.8L37.5 27.8L37.5 30.9L35 30.9L35 34L40 34L40 40.2L37.5 40.2L37.5 43.3L40 43.3L40 49.4L37.5 49.4L37.5 52.5L40 52.5L37.5 52.5L40 52.5L37.5 52.5L37.5 58.7L40 58.7L40 64.9L45 64.9L45 68L47.5 68L47.5 71L52.5 71L55 74.1L57.5 74.1L57.5 77.2L62.5 77.2L65 80.3L72.5 80.3L75 83.4L75 80.3L75 83.4L90 83.4L90 80.3L87.5 80.3L87.5 77.2L92.5 77.2L92.5 74.1L90 74.1L92.5 74.1L95 77.2L95 80.3L95 74.1L97.5 74.1L95 74.1L97.5 74.1L97.5 71L95 71L97.5 68L97.5 71L97.5 68L100 68L97.5 68L97.5 64.9L97.5 68L95 68L95 64.9L97.5 64.9L97.5 61.8L92.5 61.8L92.5 58.7L90 58.7L90 55.6L85 55.6L87.5 58.7L85 58.7L87.5 58.7L85 58.7L85 61.8L85 58.7L82.5 58.7L85 61.8L82.5 61.8L82.5 58.7L80 58.7L80 61.8L80 58.7L82.5 58.7L82.5 55.6L85 55.6L80 55.6L80 58.7L80 55.6L77.5 55.6L80 55.6L77.5 55.6L80 52.5L85 52.5L82.5 52.5L82.5 49.4L77.5 49.4L80 46.4L77.5 46.4L75 43.3L67.5 43.3L67.5 40.2L67.5 43.3L67.5 40.2L65 40.2L65 37.1L62.5 37.1L62.5 40.2L60 40.2L62.5 37.1L60 37.1L60 40.2L50 40.2L52.5 40.2L52.5 37.1L55 37.1L55 34L42.5 34L42.5 30.9L47.5 30.9L47.5 27.8L50 27.8L47.5 27.8ZM15 0L2.5 0L2.5 3.1L0 3.1L2.5 6.2L2.5 12.4L0 12.4L2.5 15.5L7.5 15.5L10 18.6L17.5 18.6L20 21.6L22.5 21.6L22.5 18.6L30 18.6L30 15.5L40 15.5L40 12.4L37.5 12.4L35 9.3L32.5 9.3L32.5 6.2L27.5 6.2L27.5 3.1L27.5 6.2L25 6.2L25 3.1L20 3.1L20 0L15 0ZM40 18.6L35 18.6L35 21.6L37.5 21.6L35 24.7L37.5 24.7L40 21.6L40 24.7L40 21.6L42.5 21.6L40 18.6Z', w: 100, h: 83.4, b: [14.18, -38.73, 14.58, -38.4] },
MU: { d: 'M4.8 5.3L4.5 5.7L4.4 5.5L4.4 6L4.2 5.7L3.9 5.7L4 6L3.2 6.9L3.4 7.6L3.1 7.6L3.1 8.1L3.2 8.1L2.9 8.1L3.1 8.4L3.1 8.2L3.1 8.4L2.7 8.2L2.6 8.8L2.6 8.4L1.9 8.6L1.5 9.1L1.5 9.5L1.1 9.6L1.1 10.3L0.8 10.5L1 11.2L1.1 11.2L1.1 11.3L0.8 11.3L0.8 11.9L1 12L1.1 11.9L0.8 12.2L1.1 12.4L0.5 13.4L0.5 13.2L0 13.4L0 13.8L0.2 13.6L0.6 13.8L0.6 14.1L1 14.1L1 13.9L1.1 14.3L1.6 14.4L2.3 14.3L2.6 14.6L3.6 14.6L3.6 14.8L3.6 14.6L4.2 14.6L4.2 14.4L5 14.3L5 14.1L5.2 14.3L6 13.9L6.5 13.4L6.3 13.2L6.6 13.2L6.6 13.4L6.8 13.2L6.5 12.9L6.5 12.6L6.3 12.7L6.1 12.4L6.3 12.4L6.3 12L6.8 12.2L7.1 11.9L6.9 11.7L7.6 11.5L7.4 11.3L7.6 10.5L7.4 10.5L7.6 10.7L7.9 10.5L7.8 10.5L7.9 10.3L7.8 9.8L7.8 10L8.1 9.6L7.4 8.9L7.6 8.8L7.3 8.4L7.1 8.4L7.3 8.6L6.9 8.4L7.1 8.2L6.8 8.2L7.3 7.7L6.8 7.6L6.8 7.4L6.6 7.6L6.8 7.6L6.1 7L6 5.8L4.8 5.3ZM99.5 0L98.9 0L98.5 0.3L98.4 0.2L98.1 0.7L97.4 0.7L97.3 1.4L97.7 1.2L97.6 1.4L98.1 1.7L98.7 1.2L98.9 1.4L98.9 1.2L100 1L100 0.2L99.5 0.2L99.5 0Z', w: 100, h: 14.8, b: [57.31, 20.07, 63.5, 20.98] },
MV: { d: 'M6.4 64.9L6.3 65.9L6.5 65.3L6.4 64.9ZM6.7 62.9L7.1 62.5L6.5 62.6L6.7 62.9ZM0.7 98.4L0.5 98.8L0.9 99.3L0.5 98.6L0.7 98.4ZM4.8 94.6L4.6 94.1L4.6 94.6L4.8 94.6ZM1.6 2.6L2 2L1.7 2.1L1.6 2.6ZM5.5 25.6L5.6 25.5L5.2 25.8L5.5 25.6ZM2.6 0L2.4 0L2.6 0.1L2.6 0.5L2.4 0.7L2.6 0.7L2.6 0ZM0.4 4.2L0.5 4L0.3 3.8L0.4 4.2ZM4.8 13L4.4 12.5L4.8 13ZM1.4 99.9L1.7 100L1.4 99.9ZM2.5 98.7L2.6 98.4L2.4 98.4L2.5 98.7ZM4.6 87.1L4.3 87.1L4.3 87.3L4.6 87.1ZM2.2 1.3L2.2 1.6L2.2 1.3ZM0.1 4.6L0.1 4.9L0.1 4.6Z', w: 7.1, h: 100, b: [73.04, -6.95, 73.58, 0.7] },
MW: { d: 'M28.7 28L28.3 25.7L26.6 24.8L26.2 23.4L24.7 22.4L24.7 21.4L24.3 21.4L25.2 19.7L25.2 17.4L24.6 15.7L23.8 15.1L24.2 14.3L23.7 13.6L23.8 10.6L22.9 7.8L20.9 5.1L17.2 1.5L16 2.3L16.3 3.2L15.8 4.4L13.7 2.7L9.8 3.2L8.4 1.5L6.1 1.7L4.3 0L3.8 0.3L3.4 1.1L4.5 1.7L4.4 3.4L5 3.3L5.4 3.9L5.6 2.9L6.6 2.9L7 4.8L8.6 6L8.9 7L8 8.8L11.2 10.9L11.3 13.6L12 13.7L11.9 14.4L13 15.4L11.7 16.3L10 18.5L8.3 18.5L8.1 19.1L7.1 19.4L7.9 19.7L7.8 20.6L9.3 22.8L7.8 25.5L7.9 26.4L7 26.1L7.4 27.5L7 28.3L8 28.3L8.4 31L7.4 35.5L8 35.7L8.8 38L10.2 37.8L11 38.3L8.8 40.7L7.9 40.3L6.5 41.8L6.1 41.2L5 41.2L4.4 41.9L4.1 42.9L3.4 43.4L3.5 44.6L4.5 45.2L3.9 47.4L4.4 49.1L3.4 50L3.1 51.8L2.1 52.4L2.3 53.1L0.9 54L0.3 53.7L0 54.4L1.6 54.8L2.3 55.7L1.3 56.4L2.9 56.9L4.5 60L5.9 58.5L7.9 59.7L8.4 62.1L9 62.1L10 64.4L11.8 65.6L12.5 67.3L13.2 66.8L13 65.7L14.4 66.4L15.8 65.3L17.8 65.6L17.7 65.2L21.1 64.3L21.6 64.3L22.4 66L23.2 66.4L23.6 67.6L23.2 68.3L24.4 72L23.8 73.1L24.2 76L23.3 76.5L23.2 77.5L22.2 77.7L22.4 80.1L21.3 81.8L20.1 82.5L19.8 83.9L22.2 86L21.7 86.8L21.9 88.6L23.8 89.3L24.1 90.7L25.2 91L25.2 91.8L26.2 92L26.3 92.9L27.2 93.1L28.2 95L29.1 95L30 96.2L31.2 96.2L31.3 97.5L29.8 98.4L30.6 99.5L30.3 100L33.1 100L33.1 98L32.5 97.6L33.1 96.5L32.8 94.5L31 92.5L32.5 91.5L32.8 88.4L34.3 86.8L35.3 86.8L35.8 87.4L36.2 86.8L38.1 86.5L39.5 85.6L39.9 77.7L39.1 74.4L40.7 70.9L40.1 70.9L40 67.8L30.3 55.4L27.6 52.6L24.3 52.6L23.7 50.9L23.4 43L22.4 40.9L21.4 35.7L22.7 33.2L24.1 31.8L24.7 28L28.7 28Z', w: 40.7, h: 100, b: [32.67, 9.41, 35.92, 17.39] },
MX: { d: 'M65.5 25.5L64.8 26L59.4 23.9L58 19.6L55.4 17L54.2 13.9L51.8 11.4L48.7 10.9L47.6 11.4L46.9 13.4L46.1 14.3L45.2 14.2L41.5 11.9L40.2 8.2L35.2 3.7L29.4 3.7L29.4 5.4L19.9 5.4L7.6 0.9L7.9 0L0 0.8L1.7 3.4L1.4 4.5L2.6 6.8L3.5 7.4L4.7 11.4L7.1 12.9L10.1 16.1L10.2 17.9L9.4 18.4L10.4 19.3L9.3 18.5L6.8 18.5L6.9 19L11.5 22.8L12.7 22.6L13.1 21.8L12.8 22.5L15.6 24.5L16.6 26.1L16.3 29.7L16.6 29L16.6 30.1L16.9 29.5L17.5 30.8L18.1 30.6L22.3 34.1L23.4 36.6L25.1 35.8L25.2 34.3L22.7 31.4L22.2 32.3L21.4 31.7L21.2 29.3L19.2 26.2L18.3 22.8L17.4 22.1L17.6 23.3L17 21.3L14.3 18.6L14.1 16.4L8.4 10.4L7.1 3.3L9.7 4.8L10.4 4.1L11.5 5.4L13.2 5.9L13.3 7.9L16.3 14.4L19.8 18.3L21.8 18.5L21.7 20.6L23.6 21.3L24 22.6L25.2 22.7L26.5 24.3L25.4 25.1L25.4 26.6L29.9 28.8L30.1 30.4L31.6 30.8L31.7 31.5L30.7 30.9L32.8 32.1L33.1 32.5L32 31.8L36.7 36.7L37.8 39.9L39.3 41.7L38.1 44.1L39.1 44.9L37.6 45.5L40 49.2L43.7 51.1L44.8 52.7L50 54L52.8 56.3L57.4 58.3L60.3 58.8L63.7 60.9L67.9 61.9L72.3 60.1L75 60.2L78 61.8L82 65.7L82.5 63.9L82 63.2L83.6 60.5L87.8 60.5L88 59.3L84.5 56.4L86 56.4L86 54.4L93 54.2L95.7 50.8L95.6 52.1L96.4 53.2L97.6 49.2L96.8 49.5L97.8 48.4L96.7 48L97.6 47L97.6 47.7L97.6 46.3L99.6 43.9L99.8 42.1L99.1 41.3L98.4 41.9L95.4 41.1L88.4 43.1L86.9 49.1L84.3 51L85.2 51.3L84.2 52.3L82.7 52L83.2 51.7L82.8 51.3L81.5 51.5L74.7 53.6L72.2 51.3L70 51.4L68 47.3L65.6 44.5L63.7 39.5L65 41.5L64.8 42.5L65.2 41.3L63.8 39.8L63.3 37.7L63.4 30.7L64.1 29.3L63.6 28.1L64.9 27.5L64.5 29L65.5 25.5Z', w: 100, h: 65.7, b: [-117.13, -34.66, -86.74, -14.69] },
MY: { d: 'M77.9 11.7L77.7 11.2L76.5 12.1L77 14.1L76.1 15.7L74.1 14.5L73.8 13.2L72.3 12.7L72.1 14.5L67.4 20.2L59 22.6L59.3 24.5L57.9 24.1L58.1 25.4L57.6 25.5L58.6 25.7L58 25.6L57.3 27.2L57.9 28.2L56.7 28.5L57.7 29.7L55.7 28.5L55.2 29.2L53.3 27.3L53.1 28.1L51.1 28L49.7 26.9L49.7 25.9L49.2 26.9L49.9 27.5L49.8 28.3L54.5 32.3L58 31.1L61.1 31.7L62.7 30.9L63 29.3L64.5 28.5L67.4 28.6L67.1 29.4L70.5 30.4L72.3 29.2L75.5 29.3L76.2 27.1L77 26.8L76.3 25.4L79 23.7L78.1 23.2L78.1 22L80.6 20.3L81.1 18.8L80.6 16.3L82.2 13.8L83.1 14.4L83.7 13.8L85.2 14.4L85.8 13.7L89.4 14L90.8 15.1L91.3 15.1L91.4 13.6L93.3 14.7L96.2 14.1L96.4 13.2L95.9 13.5L93.9 11.3L95 10.5L96.9 11L99.4 10.1L99.9 8.9L99.4 8.3L97.9 8.7L97.7 8L97.6 8.6L96.7 7.2L96.1 7.8L96.2 7L95.2 6.3L93 7.1L92.8 6.5L94 6.3L93.2 5L92.2 6.1L91 5.9L91.7 5.6L91.1 4.3L91.8 4.2L91.9 3L90.8 2.1L90.6 2.8L89.6 2.2L88.9 0.1L86.7 2.5L87.4 1.1L86.7 0L86.1 1.9L83.3 4.8L82.2 7.6L80.7 7.3L79.5 8.6L79.4 9.1L80.8 9.4L79.7 11.1L78.4 11.2L79.4 14.3L78.1 13.9L77.9 11.7ZM10.3 4.2L8.9 6.8L7.6 5.8L5.3 7.5L4.5 6.5L5.2 4.1L3.8 4.2L3.2 2.7L1.5 2.7L0.3 1.7L0 3.3L1.6 7.2L1.1 7.2L1.3 10.5L2.8 12.3L2.3 14.3L4.1 16.1L3.7 15.7L3 16.4L4.1 16.6L3.6 17L5.9 19.3L6.5 20.8L6.1 21.9L8.7 23.2L9 24.2L16.8 28.6L17.6 30.2L18.6 28.9L20.3 29.3L19.9 28.1L20.9 28.7L20.7 29.6L21.7 29.6L19.4 23.3L17.3 21.5L17.5 18.4L16.7 16.9L17.6 14.2L17.3 11.7L15.7 8.7L12.4 6.1L11.6 4.5L10.3 4.2Z', w: 100, h: 32.3, b: [100.12, -7.05, 119.28, -0.85] },
MZ: { d: 'M15.4 99.9L15.8 94.9L15.4 96.2L14.4 95.6L14.2 96.1L13.5 94.2L12.7 94.2L13.7 94.3L14.3 92.6L14.6 93.6L15.2 91.5L18.9 89.1L28.2 85.6L30.4 82.4L30.6 80.4L29.4 81.9L29.3 81.4L30.9 75.2L30.6 70.2L30.5 71.1L30.3 69.8L29.4 70.4L29 66.7L27.6 63.7L28.1 62.4L26 60L25.3 60.1L25.9 59.8L25.6 57.9L26.2 57.9L26.1 56.2L25.1 56.2L26.2 56L25.6 54.9L27 55.9L32.6 50.5L35.2 49.8L35.7 48L38 46.1L37.3 45.8L38.8 45.1L37.9 44.1L38.9 44.8L40.2 43.3L39.7 42.9L40.3 43.3L45.3 40.6L45.1 39.8L45.6 40.5L48.7 38.8L50.9 38.8L52.1 37.5L51.9 36.7L52.2 37.4L55.5 35.3L54.9 34.3L56.4 33.8L60.2 28.3L59.8 27.6L59.1 27.9L60.3 27.2L59.9 26.4L60.7 26.8L59.9 25.9L61 25.8L61 23.5L60.2 23.5L59.8 24.4L59.8 22.7L60.5 22.4L59.1 21.9L59.9 21L59.7 18.1L59 17.9L59.6 17.2L59.7 14.7L59.1 15.2L58.6 14.6L59.9 13.4L58.9 12L59.5 11.3L58.9 10.6L59.4 9L58.6 7.1L59 5.4L58.2 5L59.2 4.3L59.1 2.8L59.8 2.1L59 1.8L59.9 1.3L59 0.6L59.2 0.1L51.9 4.2L49.8 4.1L47.6 5.6L44.3 4.6L43.3 6.3L41.4 7.2L37.9 6.3L36.9 7.4L34.5 7.3L34.3 6.3L32.4 5.5L30.5 6.5L25.4 6.4L23.9 10L25.2 17.6L26.7 17.7L28 19L32.4 24.7L32.8 26.1L32 27.7L32.2 32.9L29.9 33.4L28.3 36L29.2 36.9L29.3 39.5L28 39.5L28.4 37.7L24.2 34.2L24.3 33.1L23.2 32.1L25.2 28.5L24.9 24.3L24 23.1L20.1 23.7L19.8 24.5L17.4 20.8L0 26.7L1 29L1.2 32.8L6.2 32.8L9.7 35.1L15.9 36.9L15 38.2L16.3 40.7L15.6 44.9L16.4 46.8L15.3 47.8L15.6 49.4L14.4 49.7L14.3 50.5L15.3 51.2L14.7 53.7L15 54.9L15.9 54.8L16.3 55.5L16.2 57.1L15.5 57.1L14.9 59.2L13 60.9L13.2 62.5L12.3 63.9L13.1 65.1L6.3 71.8L7.7 78.4L10.2 83.7L9.8 93.4L10.8 94.5L11 99.8L15.4 99.9Z', w: 61.1, h: 100, b: [30.22, 10.53, 40.84, 27.91] },
NA: { d: 'M0.1 2.2L0 5L0.4 7.8L2.1 11.9L4.4 13.8L6.1 16.5L9.8 24.5L10.9 25.5L12.3 30.4L15.3 34.9L16.6 37.3L16.3 37.7L18.9 40.6L20.5 43.9L20.7 46.8L20 47.6L20 48.2L20 46.5L19.7 47.4L20.4 50.1L20.3 51.4L20 51L20.3 50.5L19.9 50.8L20.5 52.6L20.2 56.7L21.2 59.2L21.2 60.2L22.3 61.6L23.2 64.4L22.6 66.2L23.2 68.1L22.9 70L24 72.5L23.6 73.1L23.9 73.4L23.7 74.5L25.1 75.7L25.4 77L25.2 77.7L25.1 77.2L24.7 77.7L25.2 79.4L25.9 79.8L26.3 82.9L28 85.5L28 86.3L29.2 87.8L29.1 88.2L34.8 93.8L36.3 92.6L37 92.8L37.5 91.7L37.1 90.9L37.6 90.8L37.4 90.4L37.9 90.3L37.7 89.9L38.1 90.1L38 89.3L39.5 88.8L40.3 89.4L40.5 90.7L41.3 90.5L41.7 91L41.9 92L41.3 92.6L41.7 93.3L42.1 93.3L41.9 94.6L43.3 94.4L43.7 95.1L44.4 94.8L45.8 95L46.7 96L47.6 96.3L51.6 95.5L53.6 95.9L54.4 96.7L55.8 96.2L55.5 95.5L55.6 94.9L57.2 94.5L57.8 93L59.8 92.8L60.2 92.2L61.1 92.2L61.1 39.6L68.5 39.6L68.5 10.6L71.8 10.6L84 8.1L85.6 8.1L86.2 9.7L87.2 10.2L87.4 11.5L87.9 12L92.1 8.2L92.9 8.4L93.3 7.8L93.9 7.7L94.5 8.6L95 8.6L96.2 7.1L97.7 6.4L98.1 6.8L100 6.4L99 5.7L98.7 5.8L98.9 5.5L97.9 4.6L96.1 4.2L94.7 4.5L94.2 4L92.5 3.9L71.7 8.3L69.4 7.5L67 8.4L65.2 8L63.5 7L62 7.4L60.4 7L59.2 7.4L58.7 6.9L57.8 7.1L55.1 6.6L52.9 6.7L50.9 5.3L49.4 3.3L18.3 3.3L17.5 3.7L16.3 3.6L13.1 1.4L12.8 0.4L10.6 0L9.2 0.2L6.4 2.2L3.6 2.2L2.5 1.4L0.5 2.4L0.1 2.2Z', w: 100, h: 96.7, b: [11.74, 17.21, 25.26, 30.29] },
NE: { d: 'M25.9 29.6L25.9 43.8L25.6 43.8L25.5 47.8L24.9 48.1L24.2 49.8L24.1 50.8L23.5 52.3L23 52.7L22.3 52.7L21.3 53.8L21.3 54.4L21.1 54.6L18.1 54.2L18.1 54.7L15.5 54.7L7.3 55.1L5.1 57.1L3.3 57.4L2.3 57L1.5 57.2L0.5 57L0.5 57.6L0.1 58L0.5 58.6L0.1 59.4L0 60L1.6 63.6L3 65L2.8 65.4L3.9 65.5L4 65.9L5.2 66.2L5.4 66.8L7.1 67.7L6.8 67.8L6.6 67.5L6.6 67.9L6.3 67.9L5.2 67.6L5.2 69.3L10.8 72.5L11.5 71.7L12.3 71.7L12.6 72L12.6 72.3L13.1 72.7L13.4 73.7L12.6 73.7L12.1 73.9L12.1 74.2L14.1 77.1L14.1 76.8L14.4 76.8L14.5 76.4L14.1 75.9L14 74.9L15.8 74.5L16 74.6L16.2 74.1L16.9 73.8L19.6 76.3L19.9 77.2L21 77.4L21.2 77.8L21.5 77.9L21.4 78.2L21.8 78.5L22.2 78.1L21.8 77.3L21.8 77L22.2 76.6L21.9 75.7L22.2 74.8L22 73.1L23.9 71.6L24.9 70.1L25.2 68.4L25.1 66.9L25.7 66.9L27.2 65.6L29.7 64.9L30.2 65.3L32.1 65.2L32.8 64.6L33.9 64.2L37.9 65.8L38.6 65.6L39.6 66.1L41.3 67.8L42.8 70L43.9 69.9L44.6 69.1L46 69.3L48.1 67.8L48.7 67.8L50.4 68.2L52.1 69.6L53 69.6L53.9 70.5L55.6 71.1L57.9 71.2L60 71.2L62.2 68.8L63.4 68.2L65.1 68.1L66.3 67.7L71.3 67.5L74 68.3L75.4 69.3L75.9 69.3L76 69.1L76.4 69.2L76.5 69.5L76.7 69.3L76.8 69.5L77.1 69.4L77.8 69.6L77.9 69L78.2 68.9L78.3 68.7L78.1 68.6L78.2 68.7L78.4 68.2L78.5 68.3L78.5 68.1L78.7 68.2L78.9 68L79 68.2L79 67.9L79.3 68L79.5 67.5L80 67.4L79.9 67.2L80.1 67L80.2 67.1L80.1 66.8L80.2 66.9L80.2 66.7L81.1 66.7L81.4 66.4L81.6 66.6L82 66.4L81.9 66.6L82.2 66.5L82.3 66.7L82.5 66.2L82.7 66.2L82.6 66L83 65.4L83.2 65.5L83.1 65.3L85 65.4L84 60.5L85.5 59.8L85.3 59.3L86.2 58.8L86 57.8L86.6 56.6L89.8 52.2L91 50.9L97 45.5L97.4 31.8L98.5 24.1L100 21.4L97.6 20.2L97 18.9L96.3 18.5L96.3 18L96.7 17.8L96.9 17.3L95.5 14.1L94.9 13.7L94.9 10.3L93.7 3.5L88.6 6L85.6 3.2L84.4 2.4L74.7 0L62.9 7.3L46.2 18.1L35.7 27.6L25.9 29.6Z', w: 100, h: 78.5, b: [0.16, -24.2, 16, -11.77] },
NG: { d: 'M95 6.9L91.3 1.5L88.8 1.5L87.7 3.3L84.8 3.4L83.3 5.3L82.5 5.2L81.7 7.1L78.6 6.7L73.2 4.4L66.6 4.5L61.1 6.1L58.2 9.2L52.5 9.1L47.8 7.1L45.7 5.2L43.4 4.7L39.8 6.7L37.9 6.5L37.1 7.4L35.6 7.6L31.4 2.5L23.9 0L21.5 1.3L18.4 0.9L15.1 1.8L12.3 3.5L12.1 7.7L8.2 11.7L8.5 18.3L7.4 19.5L7 22.1L8.9 23.5L9 26.1L9.8 27.3L9.4 29.5L7.8 29.6L7.5 30.6L8.3 31.6L7.8 33.4L5.6 34.7L5.8 35.7L3.9 37.8L3.6 40.6L0.9 41.3L0.9 45.6L0.2 47.2L0.8 48.3L0 50.8L1.2 54.3L0.4 63.5L6.2 63.4L6.2 62L6.8 61.6L6.9 62.3L8.7 61.5L12.8 61.8L12.9 63.2L10.3 62.5L12 62.3L10.1 61.6L6.2 63.4L10.5 63L14.2 63.6L17.3 65.5L20 68.6L22 67.4L22.5 68.3L22 69.9L23.1 69.5L22.5 70.5L23 71.5L24 71.8L22.1 72L22.5 73.9L23.4 73.7L22.6 74.1L23.7 76.6L23.2 75.9L25 79L26.9 80.3L28.4 81L28.1 80.4L29 79.7L28.5 81.1L29.6 81.1L29.9 79.6L31.5 80.8L32.8 80.6L32.8 79.6L33.1 80.8L33.6 78.8L33.7 80.4L35 80.4L34.5 78.5L34.9 80.1L36.1 80.3L35.5 78.2L36 78L36.3 79.7L37.4 79.6L37.6 78.6L36.8 77.6L37.4 77.5L38.4 79.2L39.4 79.1L38.9 79.7L41 79.6L40.5 78.8L41.2 79.5L46.8 78.9L47.2 78L46.4 77.8L47.2 77.6L46.3 75.7L47 75.5L46.7 76L48.3 77.6L49.7 76.6L52.1 70.4L51.4 68.1L55 65.2L55.6 63.8L56.3 64L56.7 62.8L58.7 62.4L59.2 60.1L59.9 60.2L62.3 58L62.9 59.2L65.1 59.2L65.9 57L66.2 57.9L68.1 58.8L67.8 59.6L68.7 60.8L70.2 61L70.1 62.3L72.4 62.5L74 61.2L74.5 58.5L76.6 57.7L77 54.4L78 53.9L77.7 52.6L79.5 50.2L79.9 46.2L81.5 45.5L81.3 44.7L82.7 45L84.3 43.6L85.4 38.7L84.8 38.4L87.9 36.6L88.5 34.6L88.1 32.7L90.2 31.3L92.1 24.5L94.1 22L96 22.6L99.8 19.7L98.9 18.4L100 14.7L98.3 13.1L95.8 12.6L95 6.9Z', w: 100, h: 81.1, b: [2.66, -14.03, 14.68, -4.27] },
NI: { d: 'M87.7 92.3L85.9 91.2L87.3 92.3L85.1 89.1L83.7 84.4L84 82L85.9 80.4L85.5 79.5L89 77.3L87.7 71.9L86.8 71.4L86.6 73L85.7 73L84.4 71L86.2 69.8L86.6 66L88.1 68.7L87.9 64.5L89.5 60.6L88.6 60.6L88.1 62.4L88.6 61.1L87 61.1L87 57.9L85.9 57.9L85.9 56.6L89.9 54.6L88.6 50.5L90.5 50.3L90.8 55L90.3 55.9L89 55.5L89.7 58.6L89 60.2L92.3 59.7L91 55L92.1 48.2L91 48.5L91.9 48L90.5 41.9L90.5 36.7L91.6 33.8L91 33.3L93.8 23.4L96.7 20.4L98.7 16.4L96.3 5.7L96 6.4L94.9 5.7L95.8 6.4L94.9 6.8L93.6 4.8L96 2.7L96.5 4.8L95.6 4.5L96.3 5L95.6 5.5L96.5 5.7L97.4 2.7L99.8 0.7L96.7 1.1L97.1 0.5L94.1 0L92.7 1.1L91.9 0.5L89.5 3.9L87.3 3.9L87.7 4.8L86.8 4.1L87 4.8L85.1 5L85.7 5.5L84.6 5.9L82.9 5.5L82.9 6.4L80.7 6.6L78.5 5.5L78.9 6.8L77.4 7.3L75.8 6.1L75.2 8.4L73 7L71 9.3L66.4 7.7L65.7 8.4L63.7 4.8L61.3 4.8L60.9 6.1L58 8.2L58.2 10.5L55.8 10.2L55.8 12.5L54.3 14.8L55.8 16.6L55.2 17.7L51.6 17.7L50.3 20.7L42.6 24.1L42.6 27.2L40.9 26.6L40.7 25.2L39.3 25.6L36.3 23.6L36.9 21.6L34.1 22.3L34.1 23.6L33 23.6L29.5 28.8L25.5 27.9L20.2 28.8L19.8 31.1L20.7 31.5L20.2 33.1L21.8 39L20.7 39.9L18.7 39L16.9 40.1L16.9 44.2L16 45.3L14.1 46.4L8.4 46.2L6.6 47.3L8.6 47.6L7.5 48L4.8 47.6L3.7 45.3L2.2 44.9L2.4 44L0.4 46.2L0 48.2L4.4 51.4L3.7 50.3L4.6 50.5L7.3 54.6L8.1 54.1L7.7 54.8L13 58.8L11.4 57.9L11.6 58.6L20.2 63.6L21.5 67.6L25.7 73.7L38.9 84.2L41.8 88.5L44 89.1L46.2 86L61.1 92L66.2 89.1L71.9 92L73.2 90.9L73.8 92.7L76.3 93.8L76.5 95.9L80.7 95.6L82.4 97.4L88.1 95.6L87.7 92.3Z', w: 100, h: 97.4, b: [-87.69, -15.21, -83.14, -10.77] },
NL: { d: 'M86.9 8.7L84 7.9L84.4 6.1L80.6 5.3L79.4 1.1L76.7 0L63.6 2.7L66.4 4.9L63.4 4.9L63.6 1.9L56.9 2.7L50.6 6.1L44.5 14.7L46.3 17L45.1 22.6L52.8 23.3L50.6 26L50.3 30.4L56 33.4L50.8 32.3L47.4 34.1L39.7 40.8L42.7 43.4L35.4 40.4L37.5 41.2L40.2 37.8L37.9 37.8L39 36L37.7 31.2L40.6 31.9L43.6 27.4L39.7 26.7L37.9 20L34.1 21.8L30.9 18.8L28.2 37.5L22.1 49.3L16.5 55.2L17.4 56.3L15.6 57.4L16 55.5L14.7 55.9L15.6 60.7L23.5 65L29.1 65.4L23.9 65.8L20.8 68.3L16.9 67.6L19.2 70.1L14 69.8L16.5 72.3L19.6 71.6L21 74.1L19.6 75.6L11.5 71.6L10.8 69L4.7 69.4L2 72.7L9.9 77L12.2 77L13.8 74.5L23 78.5L23 74.8L25.1 73.8L26.6 73.8L26.6 75.9L29.1 75.9L31.8 72.7L33.4 73.8L33.4 75.9L31.8 76.3L35.2 77L37.9 73.4L39.9 79.5L42.4 79.9L42.4 81.7L47.8 80.3L51.9 84.6L55.7 85L54.2 92.9L51.5 96.8L52.8 100L60 100L58.9 98.2L61.6 93.9L60 93.6L60.3 91.8L57.3 92.1L56.7 89.3L58.7 90L63.6 85.3L61.4 85L61.2 82.4L64.8 76.7L62.3 67.2L58.5 64.3L58.5 61.4L63.4 60.7L61.8 58.8L63.2 58.1L68.8 61L68.4 59.6L76.3 58.5L78.3 56.3L75.2 52.6L83.7 46L83.7 41.2L81.9 37.5L76.5 37.8L74.9 34.5L77 34.1L76.5 30.8L83.5 31.2L83.7 24.8L87.1 17.3L86.9 8.7ZM19.9 78.5L18.5 77.4L17.4 78.8L13.8 76.3L10.2 79.2L3.2 76.3L0 77.7L0 79.5L1.6 82.4L5.2 79.9L12.4 83.9L19.9 78.5ZM14.4 60.7L11.7 61L11.3 63.6L7.4 64.7L7.2 66.5L10.2 65.8L13.5 69L16.5 67.9L13.8 64.3L10.6 64.3L12.4 62.1L17.8 65.8L16.7 66.9L22.1 67.2L23.5 65.8L14.4 60.7Z', w: 87.3, h: 100, b: [3.36, -63.51, 7.23, -59.08] },
NO: { d: 'M36.4 61.6L34.5 62.1L33.2 60L36.6 58.9L33.6 57L32.2 59L33.2 56.6L31.9 56L31.2 58.8L30.5 56.7L28.4 60.5L29.5 57L27.7 56.6L26.1 60.8L23.3 59.4L24.1 61.9L22.9 60.7L21.1 63.7L21.6 60.8L20.7 63.1L20.8 61.5L19.7 62.3L20.6 63.9L18.6 62.9L16.2 66.7L18.2 67L16 67.2L16.3 69.3L15.6 67.6L13.9 69.1L15.4 70.2L13.2 71.3L14.8 71.9L12 72.4L11.4 75.2L13 74.7L10 78.5L11.3 78.2L6.6 83L7.3 84.1L9.1 82.1L8.5 84.2L5.1 84.4L5.1 86.6L2.9 85.7L4.5 86.6L1.9 86.9L3.2 88.4L0.2 88.1L2.7 89.1L0.4 89L0.4 91.3L3.9 90.9L0.1 91.4L0.8 94.8L3.1 93.1L0.4 95.8L2.3 95.7L1.3 98.8L4.6 99.7L8.1 94.7L9.4 97.5L11.2 90.6L10.1 83.7L12.7 82.4L13.5 75.4L18.6 66.6L21.1 67.2L23 63.5L24.5 65.9L28.1 66.5L30.3 60.9L34 62L33.9 64.7L36.4 61.6ZM16 3.8L16.2 12.7L13.8 5.9L12.8 10L10.4 7.6L12.5 5.2L10.2 5.5L10.4 7L9.4 5.6L8 7.9L8.8 11.2L10.2 9.7L10.7 12.6L9.1 12.1L9.2 13.9L11.8 15.1L10.5 15.6L11.3 17.7L13.9 16.4L14.6 13.1L14.8 15.9L16.9 14.4L16 15.9L17.5 16.1L12.2 18.6L12.6 20.9L17 19.5L13.8 21.4L15.7 22.6L12.6 22.4L16.7 28.2L19.8 16.1L23.3 13.2L19.7 10.9L18.8 7.3L17.9 9.3L18.4 6.4L16 3.8ZM20.7 0.1L20.6 3.1L18 3.3L19.7 3.9L19.4 6.6L23.6 5.8L21.9 6.9L23 7.9L20.7 7.3L21.3 8.6L26.3 10.6L29.2 8.9L30.7 2.9L27.1 1.3L25.5 3.4L25 0.1L24.3 4.5L20.7 0.1ZM25.2 17.3L22.3 18.5L22.4 22.7L24.7 21.6L24.9 24L27.3 20.2L25.2 17.3ZM22.9 14.7L21.4 15.7L22.6 17.7L24.3 16.6L22.9 14.7Z', w: 36.8, h: 100, b: [4.94, -142.6, 31.06, -71.53] },
NP: { d: 'M11.9 3.6L10.7 3.8L10.3 3.3L8.5 6.4L6.6 7L5.4 9.2L3.7 9.9L4.3 12.2L2.9 14.2L2.2 14.2L3.2 16L2.9 17.7L2.3 17.4L2.6 18.5L1 19.1L0 21.6L0.2 23L2.3 23.9L3.7 25.7L4.7 25.6L5.7 26.8L5.4 25.3L6.3 24.9L9 27.2L14.1 29.5L15.5 32.7L17 32.1L17.4 33.4L22.6 36.6L23.5 35.5L24.7 35.6L29.4 38.9L32.6 38.4L32.9 41.4L38.3 42L39.7 43.8L40.9 43.2L40.9 41.7L43.6 41.8L46.8 43.5L46.6 42.1L48.8 42.4L50.2 41.2L52 43.1L56 43.6L56.9 45.2L56.4 47.8L60.3 48.9L61.3 49.9L60.9 50.4L63 50.1L63.1 51.6L64.7 51.9L68.4 50.1L69.7 51L69.7 53.2L71.1 54.3L73.2 52.9L75.4 54L77.1 53.6L82.1 56.3L86.1 54L86.6 56.2L89.4 57.3L90.9 56L92.8 56.9L96.2 55.4L97.5 57.1L98.6 56.3L99.9 51.4L99.3 48.5L97.9 47.8L97.4 46.5L98.4 43.6L98 41.4L99.9 36.4L95.5 35.2L94.3 37.1L92.8 37.1L92.3 36.3L90.9 37L90.2 36.9L90.5 36.4L87.2 37L85.7 35.2L82.6 34.2L81.8 33L81 33.5L79.9 33L79.4 35L78 35.7L77.1 34.9L75.8 34.9L75.3 32.1L74 33.2L74.6 35.5L73 35.7L71.1 32L69.9 31.3L69.4 29.1L68.3 30.9L68.1 30.2L66.5 29.8L65.5 30.6L62 29.8L62.9 25.4L61.3 25L60.6 26.3L58.8 26.5L57 25.7L56.1 24.2L54.4 24.3L53.3 22.5L51.1 21.9L51.5 20L50.7 19.8L50.4 18L50.9 17.1L49.9 17.1L49.8 16.4L47.2 15.9L43.4 18.1L38.8 11.9L37.1 12.1L35.4 10.5L33.9 10.8L33 9.7L32.3 9.8L32.8 8.7L31.6 8.8L30.7 7L25.9 5.4L26.4 4.3L25.1 3.1L24.9 1.3L23.7 1.9L19.3 0L18.4 0.3L18.4 1.1L16.6 0.4L16.5 3.4L14.7 4.3L14.6 6.3L13 6.1L11.9 3.6Z', w: 100, h: 57.3, b: [80.06, -31.99, 88.2, -27.33] },
NR: { d: 'M66.7 16.7L50 0L33.3 0L33.3 16.7L16.7 16.7L16.7 33.3L0 33.3L0 83.3L16.7 83.3L16.7 100L33.3 100L33.3 83.3L66.7 83.3L66.7 33.3L83.3 33.3L66.7 33.3L66.7 16.7Z', w: 83.3, h: 100, b: [166.91, 0.49, 166.96, 0.55] },
NZ: { d: 'M36.5 44.9L33.3 47.6L31.9 53.3L29.4 54.6L27.5 61L24.7 64.1L25.2 65.3L24.6 64.1L23.4 64.7L24.5 65.8L22.4 65.3L21.7 67.4L19.6 67.8L20.5 68.4L15.4 71.1L16.3 72.3L15.2 71.2L11.3 72.6L8.1 76.6L8.7 78L7.7 77.2L6.4 79.7L5.4 79.2L5.8 80.6L5.3 79.4L4.1 80.5L5.1 81.1L4 80.8L3.2 82.4L4.3 84.6L2.4 83L1.4 85.3L3.4 84.8L1.8 85.7L3.2 85.8L1.8 86.8L3.2 86.7L0 87.8L0.2 89.1L2 88.6L0.8 89.8L2.9 88.4L1.3 90.8L7.1 90.5L7.9 92.3L11.2 92.6L11.1 94.4L18.4 93.9L22.5 88.8L25.2 88L25.1 87.1L23.8 88.1L27.5 80.4L26 79.8L27.6 80L27.4 76.3L29.7 74.1L28.5 72.5L29.8 74L33.7 71.8L30.6 68.6L33.8 71.7L39 71.2L38.6 69.7L36.4 69.8L36.5 66.7L40.2 63.6L45.8 54.5L44.4 52.2L46.1 50.4L43.7 50.8L46.1 48.8L44.1 48.5L43.4 50.4L44.9 50.1L43 51L43.9 49.2L42.8 48.9L44.4 48.1L43.3 48.1L39.5 51.3L38.8 47.7L36.3 46.1L38.6 45.4L36.5 44.9ZM38.5 0L36.3 0.6L39.2 4L38.7 5.7L40.5 8L41.2 7.2L40.5 8.3L44.5 14.3L45.2 13.7L43.7 11.5L45.4 13.3L45.4 12.2L46.5 12.7L46 13.7L47.2 13.2L45.7 13.7L46.8 16.1L45.1 14.7L47.1 19.1L48.7 18.1L49.6 19.1L48.4 20.3L47.3 19.1L49.9 24.5L48.6 25L49.6 26.7L48.2 26.9L47.6 32.2L42.7 35.7L44.1 37.7L50.4 40.6L51.5 44.9L47.8 51L49.4 50.5L49.3 52L50.8 51.5L51.8 53.6L55.7 50.7L59.5 44.8L62.3 38.4L60.9 37.2L62 34.9L66.7 34L66.8 35.5L67.6 34.3L67.1 31.3L69.2 30L70.8 23.7L67.4 22.7L62.6 26.4L55.7 23.4L55 18.2L53.9 17.9L54.9 16.8L53.6 16.9L52.5 14.8L53.4 20.5L50.4 17.8L48.2 18L49.1 15.9L48.2 14.5L49.3 14.1L46.1 10.3L46.1 9.4L47.6 10.4L46.2 5.5L44.8 6.8L44.1 5.4L45 5.4L40.9 4.2L40.7 2.7L39.8 4.4L37.5 1.1L38.5 0ZM8.4 94.8L6.1 100L10.4 98.4L10.3 96.8L8.7 97.3L10.1 96.6L8.4 94.8Z', w: 70.8, h: 100, b: [166.43, 36.67, 178.55, 53.8] },
OM: { d: 'M48.8 0.4L48.2 0.5L48.6 1L48.1 1.6L47 2L46.9 2.8L45.9 3.3L44.4 1.7L45.3 1.7L45.2 0.5L44.2 0L43.8 0.5L43 0.5L42.5 1.2L42.9 4.2L42.3 4.6L42.7 4.8L42.1 5.3L42.1 6.1L42.8 7.3L42.8 8.5L41.9 9.6L42.8 9.9L44.2 9.7L44.9 11.5L42.8 12.3L42.2 11.7L41.7 11.7L38.9 13.1L39.5 14.4L39.9 17.2L36.1 23.2L35.8 28.1L41 36.7L33.5 60.6L0 72.4L8.7 91.7L8.3 92.4L9.2 92.6L12.4 100L15.3 99L18.8 98.5L19.5 97.4L22.1 97L22.4 96.2L23.3 95.9L29.5 95.6L30 95.8L30.2 96.4L31.2 96.6L33.3 96L33.4 95.7L33.7 95.9L35.1 94.4L35.7 94.3L36.4 93.2L36.2 92.6L37 91.2L36.1 90.5L36 89.8L36.5 88.8L37.7 88L38.3 86.3L41.3 85.4L43.8 85.5L44.3 85L48.7 85.1L48.6 84.7L49.5 84.1L49.5 83.6L50.8 82.8L51.7 79.2L51.7 77.5L53.8 75.1L58.9 73.4L63.8 73.1L65.2 72.5L64.7 71.4L64.8 70.4L64.1 69.7L64.3 67.5L63.7 66.4L63.9 64.6L63.4 64.5L65.1 60.1L65.2 57.7L66.7 56.7L66.3 55.9L66.8 55.5L67.8 55.1L67.8 54L69.1 53.3L70 53.7L69.2 55.9L71.9 56.5L72.9 55.6L73.6 53L74.6 52.2L74.5 51.9L75.3 51.8L75.3 51.3L75.2 51.7L74.5 51.7L74.4 51.3L76 49.4L75.9 48.8L78.8 45.4L82.1 43.5L83.5 39.9L85.4 37.5L85.7 35.9L87.2 34L87.6 31.5L87.1 30.1L86.7 30.5L84.4 29.8L83.9 30.1L83.9 29.8L84.2 29.9L81.3 27.4L80.3 24.9L78.8 24L78.1 22.8L78.1 22L77.3 21.5L77.3 20.7L75.7 19.5L75.5 18.3L75.2 18.5L75 18.1L74.4 18.1L73.4 16.9L70.8 17.3L68.3 15.9L65.6 15.9L64.8 15.3L64.7 15.6L64.7 15.1L62.8 15.1L57.4 13L54.6 10.3L52.7 7.4L50.8 5.4L48.8 0.4Z', w: 87.6, h: 100, b: [52, -25.84, 59.84, -16.89] },
PA: { d: 'M96.4 16.3L93.4 15.8L93.5 14.8L91.7 13.9L89 9.8L87.9 8.8L87.6 9.6L85.1 6.7L76.2 3.3L67.6 3.3L67.4 2.1L69.3 1.2L64.9 1.5L58.1 0.2L54.8 3.8L55.2 4.6L54.8 4L53.1 4.6L53.1 5.9L52.3 4.5L50.3 6.9L41.3 9.6L37.7 12.7L28.7 14.6L25.5 13.9L22.6 10.2L20.7 9.6L19.7 7.6L19.9 8.4L18.7 8.4L21.6 10.8L21.4 11.9L19 11L18.5 12L16.5 12.2L15.4 11L13.2 10.7L13.4 7.9L14.6 8.4L14.9 7.4L14.1 6.9L13.1 7.7L12.9 6.9L12.2 7.6L11 6.4L12.1 5.7L11.2 5L12.1 3.4L10.2 3.3L8.3 1L7 2.4L3.2 0.2L2.7 1.2L3.6 2.2L1.9 2.8L1.9 9.5L5.3 11L5.8 12.2L3.2 13.2L2.2 14.8L3.7 17L3.7 19.3L0 22.5L1.9 23.4L2.5 27.5L3.4 26.8L3.2 23.2L5.6 22.3L10 23.2L9.3 22.9L10.5 22.2L10.7 23.2L11.9 23L11.9 22.2L13.1 22.2L13.1 23L13.9 21.8L15.1 22.2L14.1 22.2L14.1 24.2L12.4 24.1L13.4 25.1L15.1 24.2L15.4 25.3L15.4 23.9L17.8 24.6L18.8 23.9L18.5 24.7L21.6 25.8L21.9 24.4L23.3 26.8L22.2 26.6L22.6 27.5L23.9 29L24.4 28.2L24.8 31.9L26.5 32.1L26.1 33.1L31.1 34.7L31.6 33L30.9 32.5L31.9 32.1L31.1 29.9L33.3 30.7L32.8 30.1L33.8 29.5L34.3 31.1L33.3 31.9L35.7 35.7L35.1 36.6L36.5 39L35.8 40.9L36.7 41.7L44.5 41L46 39.7L45.7 38.5L47.4 37.8L46.3 37.9L50.4 37.8L52 36.2L46.2 29.7L45.8 28.2L44 27.5L43.6 24.2L45 22.5L47.9 22.9L55.7 18.1L57 16.7L56 17.5L54.2 16.7L56 16.2L55.7 14.1L56.7 12.9L59.3 12.4L58.7 11.2L59.6 12L60.4 10.7L65.9 10.2L66.7 10.8L67.2 10.2L67.1 11L70.3 12.2L71.3 14.1L72.3 13.6L74.4 16L75 15.5L75.2 16.5L76.9 17L77.1 20.1L78.6 22.2L79.5 21.3L78.6 18.6L79.5 20.8L80.3 19.8L81.8 20.5L82 19.6L81.5 21.5L82.9 19.9L84.7 20.8L85.2 23.2L86.4 24.1L85.2 23.9L83 20.8L82.3 23.2L80.3 23.5L81.5 24.7L81 26.6L78.8 26.8L78.6 26.1L78.1 27.1L80.8 31.9L80.6 33.3L85.1 37.8L85.7 40.2L87.6 41.4L88.8 37.3L90.8 37.3L90.3 33.1L93.2 36.4L95.9 32.3L96.9 32.1L97.1 29.7L100 29.2L97.6 23L96.8 23L96.6 21L95.1 19.9L96.4 16.3Z', w: 100, h: 41.7, b: [-83.05, -9.68, -77.16, -7.22] },
PE: { d: 'M63.1 58.9L61.1 58.8L59.3 59.6L58 58.8L57.5 59.2L57.5 52.7L58.1 50.6L54.3 53.6L49.1 53.7L49.3 52.6L48.3 51L43.6 50.5L45 48.2L41.8 44.8L40.6 42.4L41.1 41.6L39.4 40.5L39.6 39.4L41 39.1L40.4 38L41.3 36.1L44 34.8L43.4 32.3L44.9 30.2L45.4 27.4L50.6 24L55.8 23.3L57.4 21.9L59.1 22L59.7 23.2L60.2 22.7L61 23.1L59.3 20.3L56.9 20.2L60.5 14.6L59.2 13.2L57.5 13.1L55.6 11.6L53.3 12.7L51.6 11.3L49.3 13L46.9 12.5L45.1 13L43.8 11.8L44.3 11.1L44 9.5L41.8 8.9L42.2 8.2L41.4 6.5L37.9 5.1L37.1 2.5L32.8 0L32.4 0.6L30.7 0.4L32.7 2.6L33 4.9L31.9 4.7L31 8.2L25.2 13.7L18.7 15.9L16.9 17.8L16.9 18.6L16 17.9L14.4 24.4L13.3 24.9L12.5 26.8L11.1 26.5L9.1 23.6L8.1 23.9L6.2 22.8L4.7 23.7L5.5 22.3L4.7 22.4L4.6 21.4L6.4 20.8L5.5 18L0.4 22.7L0 24.9L1.4 26.8L0.7 27.8L2.6 30.2L2.6 31.2L1.1 31.2L1.1 32.4L7.2 36.1L9.3 39L10 41.3L12.7 44.2L16.9 54.3L19.7 58.9L19.8 60.9L22.1 62.7L22.3 65.1L24.3 66.6L27.6 72.5L27.3 74.9L26.6 74.6L26.5 75.2L27 75.1L27.1 76.6L29 79.3L31.1 80.6L33.2 83.5L52.7 93.9L53.4 96.4L58.7 100L61.8 98.8L61.8 96.1L63.7 95.2L62.7 93.9L67.2 88.7L64.9 87.6L64 84.7L65.5 82.7L64.2 80.2L66.2 77L67.1 76.9L65.8 73.9L66.7 73L66.9 69.4L68.1 67.4L63.1 58.9Z', w: 68.1, h: 100, b: [-81.33, 0.04, -68.65, 18.67] },
PG: { d: 'M1.2 43.7L2.2 44.4L3.2 43.7L8.3 44L9.1 43.3L11.9 45.1L14.3 43.5L16.5 43L17 41.3L15.1 39.2L12.4 38.2L10.6 38.6L10 37.3L9 37.4L10.1 37.2L10.8 38.3L18.8 37.7L16.9 35.7L19.1 36.5L20.4 36.1L18.5 32.3L20.3 34.3L21.9 34.8L21.6 33.4L22.4 34.5L22 33.5L22.4 34L22.7 33.3L24.3 34.9L23.3 32.5L24.5 34.1L24.5 32.2L24.7 33.7L25.4 33.8L25 32.1L25.3 32.8L25.8 32.2L25.8 33.9L26.4 34L26.2 33.1L26.8 33.4L26.6 34.7L28.7 35.1L28.6 34.7L30.1 35.8L32.8 35.5L33 36.4L34.7 36.8L38.1 41.5L38 42.8L39.9 43.6L40.5 43.2L40.1 44.6L41.2 46L42.9 46.4L45.5 50.2L46.6 49.8L46.3 50.2L47.3 50.6L48.9 49.6L48.5 50.2L50 50.9L51.9 50.3L52.6 50.4L52.2 51.1L53.7 51.5L54.6 51.1L57.1 52L59 51.9L59.9 52.9L61.1 52.5L59.6 53.3L61.9 54.2L65.2 53.3L62.8 51.7L64.4 51.9L66.4 51.1L64.6 51.5L64.5 50.9L59.6 49.6L58.6 48.2L60.9 47.3L55.5 46.2L55.1 45.1L55.9 42.9L51.4 43.4L50.2 40.7L48.8 39.9L48.2 36.5L45.5 35.8L43.8 33.4L41.9 32.6L41.2 29.5L40.4 29.3L40.4 27.7L44.5 27.8L46.4 27.1L46.3 25.5L44.5 23.1L43 22.3L39.3 21.9L37 20.1L32.7 19.5L32.8 15.2L29.6 12.1L28.1 12.1L26.6 10.3L24.7 9.6L24.4 8.3L21 8.3L17.6 5.8L11.2 4.5L1.1 0.3L1.1 24.9L0 26.9L0.5 28.6L1.2 28.7L1.2 43.7ZM74.9 10.5L73.6 10.9L73.5 11.8L72.7 11L70.4 10.9L71.7 15.3L68.9 16L66.5 19.4L64.5 19.8L63.4 19L62.5 19.9L61.1 19.6L60.7 18L61.8 16.6L61.1 16.2L59.7 19.6L58.5 19.8L58.3 19.1L55.3 20.1L53.9 19.2L50.9 19.7L50 19.1L49.4 20.7L54.1 22.9L54.1 23.9L56.4 23.2L57.9 24.7L63.3 24.6L65.5 23.7L65.8 22.9L67.2 23.1L70.5 20.7L70.3 19.6L72.6 20.1L74.4 19.1L74.6 18.3L73.4 17.1L73.4 16.1L75.3 16L76.3 14.1L76.4 11.7L74.9 11.6L74.9 10.5ZM91.7 19L91.8 22.3L95 24.9L95.1 26.9L97.6 28.6L99.6 27.6L99.7 28.1L100 27.4L99 25.2L97.8 23.9L96.6 23.8L94.1 19.7L92.8 19.8L91.7 19ZM65.8 0L66.4 0.9L65.3 1.3L67.8 1.9L76 7.1L78.3 10.7L78.1 12.6L79.6 15.2L81.2 12.1L80 9.5L77.5 8.3L74.1 4.5L72.4 4.2L65.8 0Z', w: 100, h: 54.2, b: [140.84, 2.56, 155.97, 10.76] },
PH: { d: 'M27.5 0.1L25.3 1.2L23.7 7.5L24.2 19.4L22.3 20.5L20.5 17.7L19.4 18.4L21.7 30L23.1 29.6L24.6 33L25.2 29.8L28.6 31.5L25.4 34.4L25.9 37.9L27.1 36.7L27.7 38.6L30.7 39.2L34.1 36.3L40.5 42.6L40 36.4L44.9 40.4L45.8 43.7L51.1 44L49.7 45.8L51.5 47.3L52.3 43.3L49.2 43.4L49.9 41.9L47.4 39.5L50.4 37.7L46 35.3L44.3 38.6L43.7 35.3L41.1 33.4L38.3 35.4L37.9 34.2L37.8 36.9L34.1 35L34 30.7L31.3 25.8L32.6 21.5L37 18.7L36.5 20L39.4 13.9L37 7.1L38.5 2.7L37.6 0.9L33.5 3.1L27.5 0.1ZM61.6 67.9L62.4 74.5L59.8 73.5L58.9 75.4L56.9 74.2L56.3 78.1L53.7 77.2L52.5 80.1L48.4 82.1L49.7 78.5L46.3 76.2L43.1 80.4L37.6 82L35.5 89.5L37.2 89.8L40.7 83.3L42.3 83.8L42 85.9L44.4 83.5L44.3 85.3L46.6 86.5L46.6 83L48.5 83L52.8 86.4L50.6 89.7L51.3 93.8L57.8 97.8L60.3 96L59.6 98.2L61.3 100L63.7 96L61.1 91.3L63.2 87.3L64.7 86.4L67.2 94.7L67 89.9L68.4 90.7L70.3 87.2L68.7 77.8L66.4 77.1L68.3 75.4L67.4 71.8L65.9 72.2L61.6 67.9ZM44.9 59L41.9 62.7L42.4 66L38.8 67.7L43.5 73.9L45.7 72.3L44.4 67.8L47.7 60.6L44.9 59ZM58 47L52.8 47.2L53.8 50L58.6 53.2L57 55.1L58.1 56.8L64 58.9L61.6 54.4L62.1 49.9L58 47ZM35.6 51.9L35.6 63.5L41.4 60.6L44.7 55.4L39.4 54.9L35.6 51.9ZM17.2 55.8L15.9 58.9L17.1 61.1L15.2 59.4L15.6 62.9L3.4 73.8L0.1 79.2L9.8 72L11.8 67.1L19 62.7L17.2 55.8ZM24.2 39.7L29.2 49.4L31.5 49.2L32.7 42.9L28.4 39.7L24.2 39.7ZM53.4 54.8L53.7 59.6L56 59.6L56.5 65.3L58.6 66.6L58.1 63.7L59.2 65.4L60.4 64.2L58.3 61.1L58.2 56L55.3 56.7L53.4 54.8ZM51.3 56.9L46.2 66.6L45.8 71.1L51 63.7L51.3 56.9ZM53.1 65.3L49.7 67.4L49.8 69.3L55.1 68.6L53.1 65.3ZM45.3 46.8L44.6 52.2L47.5 49.7L51.4 53.5L50.2 49.8L45.3 46.8ZM52.5 35.4L51 38.7L52.2 39.8L54 37.7L52.5 35.4Z', w: 70.3, h: 100, b: [117.17, -18.99, 126.6, -5.57] },
PK: { d: 'M0 53.7L5.8 62.4L10.7 65.1L11.9 64.9L11.8 72L14.4 72L15.2 73L14.4 76.3L9.7 76.8L8.4 78.9L6.3 79L6 82.6L5 83.2L5.4 87.6L8.1 86.1L9.3 87L10.3 85.8L16.3 86.3L16.9 85.1L19.8 85.4L19 84.8L20.1 84.3L20.8 84.6L20 85.4L22.9 85.8L23 86.6L24.2 85.2L35.1 84.9L34 83.9L32.3 84.6L34.1 83.3L36.4 86.3L35.9 88.9L39.8 89.1L39 90.2L40 90.1L39.2 90.5L40.6 92.3L39.8 92.2L39.9 93.1L42 95.8L43.6 94.6L43.9 96L46.4 94.4L48.8 94.6L49.3 92.3L49.5 93L54.1 92.5L56.8 93.3L60.2 91.6L60.2 92.8L61.6 92.9L63.5 91.7L62.7 90.8L63.4 89.8L60.7 84.9L60.7 82.9L58.2 82.8L57.2 81.2L57.7 77L53.5 75.7L54 72.6L58.9 66.8L60.2 66.8L61.5 68.9L68.3 67.1L70.8 62.1L74.8 59.6L77.6 53.1L80.9 51.5L80.9 49.2L85.7 45L84.5 44.6L85.4 42.1L84.4 40.2L89.9 36.2L88.2 34.7L85.6 34.6L85.7 32.1L84.5 32.8L81.4 29.3L82.5 27.3L81.1 25.5L83.1 23.7L80.9 23.3L81.4 21.7L79.8 20.9L80.9 18.6L83.8 17.5L92.4 19.6L93.9 18.3L98 17.9L98.4 16.5L100 16.1L98.5 10.8L97.3 8.9L94.7 9.7L93.4 7.8L94.1 6.6L93.4 3.7L91.6 2.4L90.3 2.8L90.1 1L88.5 0.4L86.9 1.2L85.4 0L81.7 1.9L80.4 1.2L70.5 2.6L67.8 5.3L66.7 4.6L64 7.9L66.2 10.4L66.9 12.6L66.1 13.6L67 14.4L62.9 19.3L63.9 20.8L63.3 23L60 23.9L55.9 23.2L58.6 28.4L53.5 30.7L53.2 33.5L51.9 34.8L52.4 38.7L49.5 41.2L47.8 39.5L46.8 40L47.5 40.3L45.2 39.4L44.1 40.9L41.5 41.7L42.8 43.2L36.3 44L34.2 45.9L33.4 53.8L26 56L22.7 55.6L20 57.1L16.8 56.3L9.8 57L0 53.7Z', w: 100, h: 96, b: [60.87, -39.98, 77.01, -24.48] },
PL: { d: 'M93.6 15.1L93.1 11.3L89.9 8.9L89 9.1L89.1 8.4L87.4 7.4L85.1 8.4L73 8.9L56.2 6.9L55.3 8.4L53.5 8.9L52.2 10.7L52.6 9.6L51.2 9.8L50.9 8.6L53 8.1L55 6.5L51.8 8.3L48.2 8.3L48.1 8.8L48.1 8.3L46.5 8.1L46.7 8.6L44.3 6.5L42.9 0.9L45.7 2.6L46.8 4.3L47 3.8L42 0L37.6 0.2L31.3 1.7L27.6 4.3L23.4 5.5L21.4 8.3L21.9 7.9L21.9 8.6L20 9.5L20.8 9.3L20.2 10.1L19.3 10L19.9 9.6L17.4 10.1L6.1 13.9L6.8 13.9L6.7 14.9L5.3 16.1L4.2 19.3L4.8 21.1L1.6 19L3.3 26.8L2.3 30.3L0.2 31.8L0 34L1 34.1L5.2 38.1L5.2 39.6L4.1 41.2L4.7 43.1L6 43.6L5.6 45.8L6.4 46.6L4.7 50.4L6.4 53L6.1 55.1L8.3 56.1L9.2 59.1L8.6 62.4L7 65.6L8.8 65.6L8.6 63.4L10.5 63.2L10.6 63.9L11.6 63.9L12.5 67L13.3 66.6L15.7 67.7L16.9 67.3L17.3 68.8L18.6 68.4L18.8 69.9L19.7 68.9L20.9 69.4L21 68.8L22 68.8L23.1 70.2L22.4 71.4L20.7 72.2L20.8 72.8L22.3 73.3L25.4 77.7L27.1 76L29 75.7L27.3 72.8L27.7 72.2L32.2 74.1L32.2 75.2L35.1 75L35.7 74.1L36.3 76L34.6 76.7L35.9 77.5L36.4 78.9L38 79.5L39.2 79.1L39.1 78.1L41.7 80.5L44.5 80.5L45 83.6L46.8 84.2L47.1 86.6L48.4 86.8L48.8 88.6L50.6 88.3L51 86.6L53.3 85.3L54.9 88.3L56.5 88.3L56.3 91.5L57.9 90.9L59.5 91.8L59.9 89.9L61.8 89.4L61.8 88.5L62.9 88.2L64.1 88.8L66 88.2L66.6 89.4L68.2 90L69.6 88.9L69.3 88L70.7 88.5L71.3 87.6L72.9 88.3L74.9 87.7L76.3 89.1L77.1 88.6L78.3 89.2L78.9 91.2L80.7 91.7L80.8 92.3L82.3 92.3L82.7 93.1L84.4 93.1L87.3 94.6L87.4 93.1L85.6 92L86 89.1L84.9 86.5L91.3 77.7L95.4 74.1L95.5 73.3L98.5 72.8L99.5 69.2L98.1 66.6L100 65.8L98.4 64.7L97.2 61.2L94.9 58.8L95.5 56.9L94.1 55.1L93.8 51.7L94.9 50.9L95 46.2L93.4 45.1L93.6 44.6L90.7 44L90.3 43L92.8 38.9L96.1 37.6L97.9 35.9L98 31.8L97.2 29.8L97.7 28.5L95.2 22.8L93.6 15.1Z', w: 100, h: 94.6, b: [14.12, -65.85, 24.15, -56.37] },
PT: { d: 'M31 95.1L30.4 90.5L29.5 89.5L29.8 88.2L31 85.4L33.1 83.3L33.4 81.2L35.5 80.8L35.6 80.1L37.2 80.5L38.3 77.1L37.8 76.7L35.9 77.6L35 75.5L32.3 72.7L33.7 69.1L33.4 67L36.7 64.5L36.5 63.5L38 61.4L36.8 59.5L35 59.7L35.3 58.5L34 58.2L33.8 56.4L32.6 55.3L32.9 53.2L29.1 49.1L32.3 49.5L37.1 48.7L37.4 46.2L38.7 45L39.3 42.3L38.1 40.4L37.1 40.2L36.8 38.8L37.2 37.8L39.2 37.4L40.5 35.5L39.5 33.7L40.4 32.5L39.5 31.4L40.4 29.6L39.8 28L40.2 25.5L38.3 22.3L39.9 22.3L42.5 18L45 17.6L46.3 15.2L47.4 15.2L49.3 11.4L46.9 9.4L44.4 9.8L43.7 8.2L44.4 5.4L43.7 5.4L44 4L43.4 3.6L41.7 4.4L40.5 3.2L39.8 4.2L38.1 4.2L37.8 3.6L36.1 4L35 3.2L34.4 3.6L34.4 5.4L31.6 6L31.1 6.8L30.5 5.6L28 6.4L28.6 5.4L26.8 4.8L24.1 6L23.7 4.4L23.5 5.4L22.6 5.4L22.2 6.4L20.3 6.8L19.1 4.6L21.2 2L20.7 1.4L19.5 1.6L19.4 0L17.4 1.2L12.8 2L12.4 3.2L9.4 5.6L9.2 8.4L9.7 9.2L10.3 9L10.4 12.2L11.2 12.8L10.4 12.4L11.6 19.3L13 19.9L12.4 19.9L12.7 23.5L11.2 29.8L11.6 29.2L12.1 29.6L11 30.4L8.8 38.8L10.6 40.2L9.4 39.8L6.4 50.4L4 53.3L4.6 54.3L3.9 53.3L2.1 54.9L1.3 54.7L2.5 56.6L0 66.2L0.6 67.7L5.4 67.5L8.3 61.8L7.4 63.9L8.6 66.4L6.7 67.2L6.7 67.7L8 67.5L7.3 67.7L7.4 68.3L6 68.3L6.7 69.5L6.3 68.5L5.8 69.1L5.2 68.7L6 68.5L5.2 67.7L3.3 68.1L4.6 70.4L4 73.1L6.4 72.7L8.8 71L10.9 71.9L11 71L10.3 71.2L11.3 70L11.5 72.5L13 73.1L10 72.3L10.4 73.6L10 72.3L8.8 71.6L10.7 75.2L10.6 78.4L9.1 81.6L10.3 82.2L10.3 85.8L11 85.9L10.1 88.2L10.3 92L7.4 99.3L8.2 99.8L9.5 98.5L12.5 98.1L13.1 97L14.5 97.4L14.6 96.8L14.6 97.8L20 97.9L23.1 100L21.9 99.1L23.5 99.6L26.4 98.7L27.7 97.2L31.3 96.5L31 95.1Z', w: 49.3, h: 100, b: [-9.5, -46.56, -6.19, -39.85] },
PW: { d: 'M60.2 1.2L60.2 0L57.9 0L57.9 2.4L60.2 4.8L60.2 8.3L61.4 8.3L60.2 10.7L56.7 14.3L53.1 13.1L52 15.5L49.6 15.5L48.4 16.7L49.6 16.7L49.6 20.3L47.2 20.3L44.9 22.6L44.9 26.2L46.1 27.4L49.6 26.2L47.2 28.6L48.4 32.2L47.2 31L46.1 32.2L46.1 29.8L44.9 29.8L46.1 29.8L44.9 29.8L46.1 27.4L44.9 28.6L42.5 27.4L42.5 29.8L43.7 29.8L40.1 33.4L40.1 34.5L42.5 34.5L46.1 38.1L44.9 39.3L46.1 40.5L46.1 44.1L48.4 45.3L49.6 47.6L48.4 47.6L50.8 47.6L48.4 45.3L52 45.3L52 42.9L54.3 42.9L53.1 44.1L54.3 45.3L56.7 44.1L55.5 42.9L55.5 35.7L57.9 34.5L57.9 31L61.4 28.6L59 25L59 26.2L59 23.8L57.9 23.8L61.4 21.4L60.2 17.9L62.6 11.9L62.6 9.5L61.4 9.5L61.4 3.6L62.6 3.6L60.2 1.2ZM37.8 48.8L35.4 46.5L35.4 48.8L37.8 50L36.6 52.4L37.8 52.4L36.6 53.6L35.4 52.4L36.6 53.6L35.4 52.4L33.1 54.8L33.1 57.2L30.7 56L31.9 56L31.9 53.6L31.9 54.8L31.9 53.6L30.7 53.6L31.9 54.8L29.5 54.8L31.9 57.2L30.7 59.5L26 56L29.5 60.7L30.7 59.5L30.7 61.9L33.1 58.4L31.9 58.4L34.2 58.4L35.4 54.8L35.4 56L35.4 54.8L37.8 54.8L37.8 57.2L39 57.2L39 54.8L37.8 54.8L37.8 51.2L39 50L37.8 48.8ZM37.8 44.1L39 46.5L40.1 45.3L41.3 46.5L37.8 45.3L36.6 46.5L39 46.5L39 47.6L40.1 46.5L41.3 47.6L41.3 51.2L42.5 51.2L41.3 50L42.5 51.2L44.9 50L41.3 46.5L41.3 47.6L42.5 46.5L46.1 47.6L43.7 48.8L46.1 50L46.1 44.1L44.9 45.3L41.3 45.3L41.3 46.5L40.1 44.1L37.8 44.1ZM15.4 84.5L16.5 84.5L17.7 81L11.8 85.7L11.8 89.3L13 90.5L14.2 89.3L13 88.1L15.4 88.1L17.7 85.7L15.4 84.5ZM30.7 66.7L30.7 64.3L29.5 67.9L28.3 66.7L27.2 67.9L28.3 65.5L27.2 65.5L26 69.1L27.2 70.3L28.3 69.1L28.3 70.3L29.5 69.1L31.9 70.3L30.7 70.3L31.9 70.3L30.7 69.1L30.7 70.3L30.7 66.7ZM2.4 96.4L0 96.4L1.2 97.6L0 100L3.5 98.8L3.5 96.4L2.4 96.4Z', w: 62.6, h: 100, b: [134.12, -7.75, 134.65, -6.91] },
PY: { d: 'M88.7 75.1L88.3 73.4L90.4 69.9L90.1 68.2L92.3 60.3L91.4 57.8L92.1 56.7L90.3 54.6L87.8 53.5L84.8 55.5L81.5 55.9L79.5 55.3L79.3 52.3L78.2 51.4L78 49.7L78.6 48.4L78 47.7L78.4 46.2L77.5 45.6L76.8 42L77.4 39.8L74.8 35.2L70.8 35.2L69.2 34L68.8 32.7L67.6 33L66.1 35.1L65.4 34.5L64 35.5L62.2 34.5L58.6 34.9L55.7 34L55.4 33L53.3 33.7L51.1 33L52.1 30.6L51.4 30.1L52.4 28.2L51.4 26.2L52.8 24L52 23.4L52.8 22.8L53.1 19.2L51.9 18.9L52.8 18.2L51.7 17.2L52.6 17L52 16.1L51.2 16.5L51.1 13.3L50.2 12.8L50.1 11.4L49.2 11.1L49.8 10.6L48.8 7.8L49.3 6.3L39.3 0L29.4 0L22.5 2L9.9 4L7.9 9.4L4.1 14.9L4.2 20.9L0.1 35.3L4.4 38.3L4.1 39.2L6.8 42.4L7.4 44.3L10.6 47.2L11.7 47.3L12.6 48.9L17 51L18.2 53.5L25.6 56.3L28.6 55.9L34.9 59.8L36.2 61.7L38.7 63.3L43.6 65.9L45.5 66L47.4 67.9L48.7 67.1L53.9 70.1L55.9 73.4L55.8 74.8L55.3 75.5L54.6 75.2L54.7 76L53.9 75.9L53.6 77.2L53 77.1L53.4 77.9L52.2 79.6L52.8 80.1L49.3 82.3L50 83L48.7 84.8L49 88.1L48.2 88L48.3 89.5L47.2 90.1L47.6 90.8L45.5 91.5L45.6 93.2L45 92.8L45 93.8L43.9 93.9L44.4 96.2L51.3 95.7L60.6 98.5L63.5 97.6L65.2 98.6L66.5 97.8L68.5 100L69.9 98.6L70 97.4L71.9 96.3L74.3 96.6L76 98L77.6 96.6L77.3 94.5L78 93.7L79.2 93.8L79.2 92.7L80.1 91.9L82.7 91.8L82.7 90.6L84.6 89.8L84.9 88.4L86.5 88L86.2 86.9L87.3 85.8L87.7 83.3L88.3 82.8L87.7 80L88.4 79.9L88.7 77.8L87.9 76.1L88.8 76L88.7 75.1Z', w: 92.3, h: 100, b: [-62.64, 19.67, -54.26, 28.75] },
QA: { d: 'M34.8 90.6L34.8 92.9L32.6 94.1L32.1 96.5L31.6 95.9L32.1 96.5L29.4 98.2L29.4 94.1L30.5 91.8L28.3 92.4L28.9 91.2L25.7 91.8L24.6 90.6L25.7 89.4L28.3 90L28.3 88.2L30 90.6L31.6 90L31.6 88.8L31.6 91.8L32.1 90L32.6 90.6L31.6 92.4L32.1 91.2L31.6 92.4L31 91.8L31 94.7L32.6 94.1L33.7 92.9L33.7 90.6L36.9 88.8L40.6 77L42.3 76.5L42.3 75.3L43.9 74.1L45.5 71.1L44.4 70.6L47.1 68.8L46 65.8L46 58.2L47.1 56.4L45.5 55.2L47.1 53.4L46 51.1L44.9 51.1L44.9 52.2L43.9 51.1L42.8 51.7L43.3 50.5L41.2 51.7L42.3 49.9L41.2 49.3L41.7 46.9L44.4 46.9L43.9 47.5L43.3 46.3L41.2 46.3L42.3 46.3L42.3 42.8L41.2 42.8L41.2 41.6L42.3 42.2L40.6 41L42.3 40.4L40.1 41L39 39.2L40.1 35.7L39.6 33.3L42.8 32.1L43.3 30.3L41.7 29.7L42.3 28L40.1 28L43.3 27.4L43.3 28.5L42.3 28L44.4 29.1L45.5 26.2L44.9 25L42.8 25.6L42.3 24.4L43.3 25L43.9 23.2L43.9 24.4L44.9 23.2L44.4 24.4L45.5 23.8L44.9 17.9L43.9 16.1L44.9 16.1L44.9 14.9L46.5 13.7L44.4 14.9L44.9 13.7L46 14.3L44.9 13.7L45.5 13.1L46.5 13.7L45.5 13.1L43.3 14.3L39.6 11.9L35.3 11.9L34.8 8.9L32.6 7.1L32.6 3.6L26.2 0L25.7 1.2L23.5 0.6L21.4 3L21.4 4.8L15.5 6.6L15.5 10.1L14.4 10.1L13.9 11.9L12.8 10.7L13.4 14.3L11.8 16.7L12.8 17.3L11.2 17.9L11.2 19.6L12.8 20.2L12.8 22L9.6 25L9.1 24.4L10.2 23.2L11.2 23.2L11.2 22L9.6 20.8L8.6 22L9.1 25L7.5 26.8L8.6 31.5L12.3 31.5L11.2 32.1L11.2 33.9L10.2 32.7L9.1 33.3L9.1 38.6L8.6 39.2L7.5 36.8L6.4 36.8L6.4 30.9L4.8 30.3L5.3 32.1L2.7 32.1L2.1 33.3L4.3 33.3L4.8 36.3L3.7 37.4L5.3 41.6L3.7 41L2.7 37.4L0.5 38.6L0.5 57L1.6 59.3L1.1 62.3L3.2 64.7L2.7 70.6L5.9 74.1L5.3 78.8L6.4 81.2L5.9 82.9L3.2 84.1L10.7 96.5L18.7 100L28.3 99.4L33.2 97.1L35.3 92.4L34.8 90.6Z', w: 47.1, h: 100, b: [50.75, -27.12, 51.63, -25.25] },
RO: { d: 'M88 69.1L87.6 68L89 65.5L88.6 60.7L91.7 56.1L89.9 58.5L90.2 57.1L89.5 57.1L90.3 56.7L89.9 56.1L92.2 55L91.9 56.1L92.4 55.3L91.3 54.7L91.5 55.2L90.3 55.8L90.3 54.6L89.5 54.6L91.9 54.2L91.4 53.3L92 52.8L91 51.2L93.5 50.6L92.8 52.1L93.1 53L93.9 52.7L94 53.6L92.2 54.2L92.4 55.2L94 53.7L98.8 52.8L100 48L99.2 48L99.7 48L99.2 47.4L99.7 47.4L99.6 45.8L98.3 44.3L95.9 43.7L92.1 45.5L92 46.2L90.4 45.3L89.8 46.1L90.3 46.8L89.4 47.1L85.4 45.5L84.9 43.7L83.5 42.8L83.6 41.7L82.9 41.4L83.7 40.8L82.9 34.1L83.1 31.5L84.1 30.6L84.7 28.3L84.1 27.7L84.6 25.4L83.1 22.7L83 20.3L79.6 17.8L79 15.3L77.6 15L77.4 12.6L76.3 12.3L74 8.7L74.5 8.2L73.4 5.7L72.8 5.7L73.1 4.4L72.3 4.1L71.6 2.1L70.9 2.2L71.2 1.7L69.4 0.3L68.1 0L64.7 1.1L62.8 4.4L52.7 5.9L51.4 8.2L49.1 8.7L45.7 4.8L44.1 4.8L42.2 5.9L39.7 4.8L38.1 5.4L37.1 4.3L34.6 4L33.9 4.8L30.8 2.4L29.9 4.3L28.1 4L28.4 4.9L26.5 6L26.7 6.8L25.7 7.6L24.2 7.9L23.4 7.3L23 8.4L21.8 7.9L20.3 10.7L18.9 11.4L18.6 14L17.6 14.5L16.9 17L14.7 19.3L15 20.6L13.3 22.4L12.9 23.7L13.4 24.3L12.4 24.6L12.2 25.8L11.1 25.8L10.6 27.7L11.2 28.4L9.9 29.4L9.7 30.7L8.6 31.7L5.5 31L4.1 33.3L2.6 32.4L2.1 33.2L1.1 32.7L0 33.3L1 35.3L2.3 35.9L2.8 37L3.3 36.7L4.7 39.1L5.9 38.7L5.4 41.7L6 42.4L5.4 43.2L6.3 43.4L8 45.6L9.7 45.5L10.1 46.7L13.3 47.9L12.7 49.8L11.6 50.1L13.8 52.1L11.7 52.4L12 53.7L14.1 54L14.4 55.5L18.4 55.9L20.1 58.3L21.6 55.5L22.9 54.7L26 56.4L26.5 57.4L24.6 57.1L24.1 58.2L23.2 58.3L24.2 60.7L25.6 61.1L25.7 62.3L29.4 64.1L29.1 65.1L27.7 65.4L27.6 67.8L30.5 68.2L33.8 67.5L41.3 69.9L44.8 68.8L47.4 69.9L50.1 69.2L54.6 70.8L58.4 69.7L62.3 65.4L72.9 63.2L74.3 63.3L75.6 65.1L77.8 65.1L78.1 64.5L79 66L81.1 65.1L81.8 67.6L84.2 68.8L88 69.1Z', w: 100, h: 70.8, b: [20.26, -55.26, 29.71, -48.57] },
RS: { d: 'M6.9 67.9L6.9 69.2L12.5 73.1L12.7 74.9L15.2 76.9L16.8 76.9L18.1 78.8L20.4 78.3L22.8 81.8L23.7 81.3L27.3 83.3L27.3 85.3L30.8 84L30 82.1L33.1 80.3L33.5 78.1L31.7 75.9L36 74.4L37.1 75.9L36.2 75.9L36.2 76.9L39.8 78.3L40.7 77.6L42.2 81.1L43.4 80.8L44 83.8L47 84.3L46.1 87.2L49.2 87.5L50.8 89.2L52.6 88.5L53.4 89.5L51.9 93.1L50.3 94.4L50.8 95.6L48.7 96.6L48.5 97.6L49.9 99.5L51.6 100L54.3 97.6L58.8 98.3L61.9 96.6L63.5 98L65.5 97.6L67.3 93.6L65.1 91.7L66.2 88L65.1 85.3L68.2 83.5L70.6 84L71.3 81.6L73.4 80.3L75.3 77.6L75.4 76.4L73.4 75.6L71.1 71.6L66.8 69.4L66 64.9L64.6 63.9L63.7 61.2L64.6 55.9L68.4 54.6L68.2 51.9L69.6 49.8L69.3 48.8L67.1 48.3L65.3 44.3L66.9 44L67.7 42.2L70.2 43L70.9 42L64.8 38.2L62.6 39.4L60.1 44.3L57.7 42.2L57.2 40.2L50.3 39.4L49.7 36.9L46.1 36.4L45.8 34.1L49.2 33.6L48.8 32.3L45.6 30.3L47.4 29.7L48.5 26.4L46.5 24.9L42.9 24.4L42.3 22.3L41.6 23.1L39.4 22.6L36.5 18.7L34.9 18.4L36 17.1L34.9 15.9L35.8 10.7L33.6 11.5L31.3 7.3L30.4 7.8L29.7 6L27.3 5L25.9 1.6L23.5 1.3L23 0.3L22.1 1.3L21.3 0.3L19.7 0.3L17.9 1.6L15.6 0L13 0.3L10.3 3.9L8 4.4L8.3 5.2L6 5.2L4.7 3.9L4.3 6L2.9 6L3.1 7L0.9 6.8L1.1 8.6L0 9.6L1.4 9.6L0.4 11.2L2.5 11.5L1.3 12L2.5 12.2L2.4 13L1.3 12.5L2.5 13.8L1.3 14.8L2 14.8L1.1 15.6L1.4 16.9L4.7 17.4L4 18.4L2.7 18.2L3.4 20.5L2.4 21.3L4.5 22L5.4 23.6L7.8 23.6L7.6 24.6L11 25.6L9.2 26.7L8.7 25.4L8 25.4L8.3 26.4L6 25.6L5.6 27.4L4.5 26.9L4.9 29.5L3.8 31.3L5.6 31.3L5.8 32L4.2 33.3L2.7 33.1L3.3 34.3L5.8 32.8L9.6 33.6L8.7 38.2L6 43L5.2 42.7L5.6 45.8L4.7 46.8L6 49.1L8.9 49.3L9.4 51.6L14.1 54.9L12.5 57.1L7.1 55.9L8.5 59.1L12.3 63.2L12.5 64.9L11.8 66.9L7.6 65.9L6.9 67.9Z', w: 75.4, h: 100, b: [18.84, -52.2, 23.01, -46.67] },
RU: { d: 'M13.9 53.4L12.7 51.1L14.3 49.4L12.6 47.3L13.2 45.3L14 45.8L15.4 43.9L17.8 45.2L22.3 44.9L22.1 41.5L27.3 39.8L30.2 42.1L32.5 41L34.6 45L36.8 44.7L39.4 46.7L42.6 44.8L45.9 46L46.9 43.5L53.2 46.4L57 45.4L59.3 46.2L60.7 42.8L63.1 42L67.9 48L70.4 47.4L67.5 52.7L69.7 52.6L73.9 47.3L74.8 43.5L73.7 41.1L72 42L70.6 40.5L75.2 35.5L83.8 35.3L83.1 34.4L85 32L87.2 31.6L87 33.5L89 30.8L90.4 30.9L84.8 37.1L84.7 44.9L89.1 39L88.1 36.8L89.3 34.2L93 33.4L93.6 34.4L95.8 31.9L99.4 31.1L98.8 28L96.8 27.6L100 27.1L100 20.4L93.8 18.3L93.8 20.9L92 19L87.6 20.1L86.2 16.8L82 17L79.3 13.8L77.3 15.5L78.3 13.8L76.6 14L78.3 13.7L74.4 12.6L73.2 14L73.9 15.7L70 15.8L69.2 14.6L67.9 17.2L66.1 14.3L66.5 11.9L63.6 10.5L62.6 12.7L57.9 10.7L56.5 11.9L56 10.1L54.2 10L54.8 10.8L51.1 12.9L56.2 7.5L55.7 5.4L56.5 6.2L56.7 5.3L54.9 2.8L51.9 3.6L52.5 2.4L50.3 1.9L51.8 1.1L50.3 0L48.3 3.7L46.9 3.6L47.5 4.9L43.2 4.7L39.1 7.2L39.1 8.6L38.4 8L39.2 10.4L34.8 11.1L37.1 19.1L36.4 16.7L35.8 17.5L36.6 15.1L33.5 13.7L31.9 14.8L33.5 16.8L32 16.3L31.1 12.7L29.9 15.8L30.4 20.1L33 20.6L33.7 23L32.3 20.5L31 20.8L29.4 25.2L27.4 24.6L29.1 24.1L30.3 21.4L29 12.6L27.6 12.4L25.8 16.4L27 21.8L22 18.8L21.3 21.6L20.7 20.4L17 21.9L17.4 20.5L12.2 24.2L11.5 23.2L12.6 22L10.4 21L11 25.6L9.8 24.7L8.8 25.6L8.6 27.9L6.2 26.9L6.7 29L4.9 27.9L4.9 25.8L3 23.7L7.4 25.5L9.1 24.2L8.9 22.7L5.6 20.1L3.7 20.6L3 18.6L0.7 20.6L2.8 30.3L0.3 33.5L2 34.3L0 35.8L0.6 39L2.4 39.6L3.6 42.1L2.9 43.5L8.4 46.1L6.1 50.5L13.9 53.4Z', w: 100, h: 54, b: [27.32, -127.74, 180, -45.28] },
RW: { d: 'M8.8 82.9L9.8 81.4L9.8 76L12.3 75.5L13.7 76.5L14.2 75.5L15.2 77L20.6 77L23 78.5L23 80.4L24 80.9L24 82.9L23 83.4L25 87.8L27.9 85.8L30.9 85.8L31.9 87.3L33.3 87.3L35.3 85.8L37.7 86.3L38.2 85.3L39.7 85.3L41.7 86.3L41.2 86.8L42.6 86.8L44.1 86.3L44.6 83.9L47.5 84.9L50 83.9L52 81.4L52.9 78.5L52 73.6L52.9 71.1L54.4 70.6L52.9 64.7L53.9 62.8L57.8 63.8L61.8 68.2L65.2 67.7L66.7 63.8L68.1 64.7L74 64.2L75 61.8L76.5 62.8L78.4 62.3L78.9 64.7L83.3 67.2L84.8 66.2L88.2 66.7L89.7 65.7L89.7 64.2L91.7 64.2L93.6 65.7L95.1 64.2L96.6 64.2L97.1 62.3L98 62.3L97.1 56.4L100 51L95.6 43.6L96.6 37.3L96.1 31.4L97.1 29.4L92.2 23L92.2 19.6L89.2 16.7L86.8 16.7L85.8 14.7L83.8 14.2L82.8 12.7L82.8 9.3L80.9 5.9L78.9 5.4L79.4 3.4L77.9 2.9L78.9 0L75.5 1L73 0.5L72.5 3.9L70.6 4.9L70.1 7.4L68.1 8.3L66.2 11.3L64.2 10.8L64.2 14.7L62.7 14.7L61.8 16.7L61.3 15.7L59.8 15.7L58.3 17.2L58.3 18.6L56.9 17.7L54.9 20.1L51.5 21.1L50 15.2L47.1 12.7L46.1 15.7L44.1 14.2L42.2 14.2L39.2 16.7L33.8 16.7L28.9 22.6L24.5 22.6L23 26L19.6 28.9L18.6 32.4L13.2 39.7L13.2 44.6L15.2 49L15.2 53.9L12.7 59.3L11.8 60.3L6.9 60.8L4.9 65.2L2.5 64.7L0.5 66.7L1.5 71.6L0 72.1L0 73.1L2 74.5L2.5 79.9L5.9 80.4L8.8 82.9Z', w: 100, h: 87.8, b: [28.86, 1.05, 30.9, 2.84] },
SA: { d: 'M1.8 15.4L1 19L1.1 19.9L0 22.3L0.2 22.6L0.6 22L0.8 22.4L2.2 22.1L3.1 22.5L2.8 22.7L4.1 23.9L4.8 25.8L5.8 26.8L5.8 27.5L8 30.2L9.1 32.7L10.1 33.2L10 34.5L11.2 35.2L12.7 37.7L12.8 39.3L12.3 39.6L13.6 41.4L13.6 42L14.2 42.5L14.8 42.3L15.6 43.1L16 42.7L16 43.1L18.4 44.9L20 48L20.1 49.1L20.9 49.7L20.2 49.2L21 50.2L21.4 52.3L21.7 52L21.1 54.1L20.7 54.1L21.3 55.6L21.7 55.3L21.4 55.8L21.9 57.2L21.5 57.7L21.9 58.7L24.5 62.4L26.7 63.4L25.7 63L26.3 62.9L28.2 64.4L28.8 65.5L29.4 65.4L29.4 66.3L30.3 66.8L30.2 67.6L31.3 69L31.1 69.6L31.6 70.1L31.7 71.3L32.5 71.7L32.8 73.2L34.1 75.1L36.7 77.1L37 79.2L37 78.4L37.8 79.2L37.7 79.9L38.6 80.6L38.9 82.3L39.8 82.3L39.7 81.8L40.5 81.6L40.7 80.9L41 81.1L41.2 80.3L40.6 79.6L41.1 79L40.7 78.4L41.6 77.8L41 77.1L41.6 76.5L43.9 77.6L46.2 77.1L50.5 77.1L51.3 77.6L54.5 78L57.7 77.9L58.9 79.5L59.8 79.5L61.1 78.7L61.8 77L64.5 73.5L68.8 71.3L82.6 69.3L96.8 64.3L100 54.1L97.8 50.5L97.5 50.9L85.4 49.3L80.8 43.4L80.7 42.5L80 42.1L79.2 42.3L80.1 40.7L79.8 40.6L79.6 41.1L78.3 41.4L76.8 40.1L75.8 38.4L75.5 36.5L74.9 36L75 36.5L73.7 34.8L74.4 35.4L73.7 33.5L73.1 33.4L73.3 32.3L73.9 33.2L74.2 30.7L73.9 31.2L73.3 30.7L73.2 30.4L73.6 30.3L73.2 30.3L73.1 29.5L73.3 29.9L73.9 30L73.3 29.3L73.4 28.8L73.2 29.2L72.5 28.8L71.3 27.8L71.8 27.6L71.1 27.6L71.4 26.6L71 27.2L70.7 26.4L71.7 26.5L70.7 26.2L70.9 27.5L70.1 27.4L70.3 27L70 27.3L69.7 27L69.9 26.3L69.3 26.3L69.7 26L69 25.9L69.9 25.8L69.4 25.1L67.6 24.8L67.9 24.5L67.3 24.2L67.5 23.6L67.6 24.2L67.9 23.7L67.2 23L67.4 22.7L66.6 22.3L66.7 21.7L66.1 21L66 20L62.2 19.9L61.1 17.3L48.1 16.3L35.6 5.8L32.6 4.3L27.7 1.1L22.1 0L20.9 0.9L11.5 3.6L16.3 9.2L14.7 10.1L13.9 11.9L10.3 12.6L9.1 14.7L7.2 16.3L1.8 15.4Z', w: 100, h: 82.3, b: [34.57, -33.98, 55.67, -16.61] },
SB: { d: 'M34.3 27.7L33.1 28.3L33 30.4L35.3 33.4L40.7 33.5L43.7 35L45.8 34.1L43.9 31.3L41.2 29.4L36.4 29.5L34.3 27.7ZM44.5 18L44.6 18.5L42.9 18.4L44.6 20.1L44 21.2L45.4 25.8L47.8 28.3L49.7 29.2L51.5 31.7L51.4 30.1L49.7 28L50.4 28.3L49.9 27L50.5 26.9L49.5 26.5L48.7 24.1L46.9 23.1L47.6 21.1L44.5 18ZM21.2 9.8L22.7 12.2L27.1 15.6L33.7 18.6L36.4 20.6L34.7 18.9L35.8 18.2L31.1 14.4L28 13.8L24.2 10.5L21.2 9.8ZM51.2 37.4L50.3 37.6L50.3 38.9L52.6 39.4L52.6 41.5L53.2 41.4L53.8 42.4L58.6 44.3L62 44.3L60.7 44.1L60.7 42.6L58.6 40.4L58 40.8L55.1 39.8L51.2 37.4ZM1 0.1L0 0.7L0.3 1.6L2.7 3.1L6.5 7.4L9.3 8.2L9.8 7.8L9.7 8.4L10.7 8.7L12.2 8.3L11.9 7.3L10.5 7.7L9.3 6.1L8.2 6L6.5 3L1 0.1ZM11.4 14.2L10.2 14.8L9.6 16.6L8.5 16.6L8.5 18L11.9 17.2L12.8 18.5L12 18.4L12.2 19L13.4 20.1L14.1 19.7L14.6 21.3L14.6 20.3L15.4 20.7L15.3 18.8L14.3 17L12.8 17L12.6 14.8L11.4 14.2ZM37.4 50.8L36.8 51.1L37.4 52.3L40 52.6L41.6 54.6L43.3 54.7L41.8 53.1L37.4 50.8ZM7.3 13.1L6.2 13.3L5.8 14.8L6.5 15.8L8 16.1L8.3 14.1L7.3 13.1ZM2.3 10.1L1.2 11.3L3.4 14.1L4.2 11.7L2.3 10.1ZM16.4 19.9L16.3 20.7L15.4 20.5L15.3 21.8L17 22.7L18 20.4L16.4 19.9ZM99 42.4L97.2 42.5L96.7 43.7L96.5 43.1L96 43.6L96.2 44.5L96.9 44.1L97.1 44.8L98 43.5L99.6 43.6L100 42.6L99 42.4ZM51.3 28.9L51.5 32L53.2 33.3L53.2 31.5L51.3 28.9ZM9.9 18.9L8.3 20.7L10.5 22.2L9.8 20.7L10.6 19.5L9.9 18.9ZM38.8 25.3L37.9 25.5L38.3 26.1L40.3 26.2L39.5 27L41.5 26.5L39.9 25.2L38.8 25.3Z', w: 100, h: 54.9, b: [156.38, 6.6, 166.09, 11.94] },
SC: { d: 'M91.6 2.7L91.5 3.2L90.9 3.4L90.9 3.6L92 4.1L92.1 4.5L91.8 4.7L92.1 4.7L92.4 5.2L92.6 5.1L92.4 4.9L92.5 3.8L91.9 3.6L91.9 2.9L91.6 2.7ZM3.1 51.1L2.4 50.8L2.4 51.1L2.8 51.3L0.7 51.8L0 51.4L0.2 52L0.9 52L1.8 51.6L3 51.5L3.1 51.1ZM94.2 0L93.9 0L94 0.4L94.6 0.7L94.8 0.7L94.7 0.4L95 0.5L94.2 0ZM1.4 50.8L0.4 50.9L2.3 50.9L1.4 50.8ZM15.3 58.2L15.5 58L15.3 57.6L15 57.9L15.1 58.2L15.3 58.2ZM89.4 1.7L89.4 2.2L89.7 2.2L89.8 1.9L89.4 1.7ZM3 54.6L3.2 54.4L3.1 54.1L2.8 54.5L3 54.6ZM95.6 0.7L95.5 0.5L95.3 0.7L95.4 0.9L95.7 0.8L95.6 0.7ZM99.7 28.7L100 27.9L99.7 28.7ZM49.3 58.2L49.1 58.2L49.4 58.4L49.2 58.6L49.4 58.5L49.3 58.2ZM64.7 27.1L64.6 27L64.6 27.2L64.7 27.1ZM70.3 11.3L70.4 11.2L70.2 11.2L70.3 11.3ZM12.9 54.4L13 54.1L12.9 54.4ZM47.3 52.5L47.4 52.2L47.3 52.5Z', w: 100, h: 58.6, b: [46.21, 4.29, 56.29, 10.2] },
SD: { d: 'M11.3 84L13.7 84.1L13.8 83.6L16 83L17.9 83.3L19.3 81.9L19.5 81.3L19.2 81L19.6 80.1L23.6 79.4L24.6 79.7L24.6 80.9L26.2 82.5L26.8 84.3L29.2 85.1L31.8 84.3L36.4 84.4L35.9 83.3L35.9 81L42.9 81L42.9 84L43.3 83.5L46.1 83.5L46.1 81.5L48.8 80.3L53.8 83.8L56.5 83.5L59.5 80.3L60.7 78.1L63.6 75.7L63.1 74.9L63.1 71.6L61.4 70.2L65.2 70.2L65.4 68.4L68.5 68.5L67.6 72.1L68.2 77.3L69.8 78.1L72.5 81.1L72.6 82.6L72 85.1L73.3 85.1L74.1 81.7L74.8 80.7L74.4 78.5L76.3 76.5L77.3 77.7L77.9 77.6L77.9 77.1L78.6 76.6L78.7 74.8L78.2 74.4L79 73.4L79.2 71L80.3 70.2L82.7 65.8L85.6 65.6L85.5 64.1L87.6 58.7L87.4 57.8L88 56L87.2 50.4L87.8 50.1L89.9 43.9L90.3 43.5L90.4 42.6L89.9 42.5L89.7 41.9L90 41L90.6 40.6L90.6 38.4L93 38.8L93.6 37.8L93.6 36.9L95.1 36.7L95.4 35.9L95.9 36.3L96.5 35.6L97.1 35.9L97.3 35.4L98 35.6L100 32.7L99.6 32.1L99.5 32.5L99.3 32L98.9 32.1L98.9 31.5L98.2 31.6L98.1 31.1L98.4 31.2L98.2 30.9L98 31.4L97.3 31.2L97.3 30.2L96.2 29.2L95.1 28.5L95 28.9L95.1 28.4L94.6 28.2L93.6 28.2L93 27.3L91.9 22.7L92.2 21.2L91.7 19.9L92 16.6L91.1 13.6L91.2 12.5L92 12.9L92.1 13.9L92.5 13.4L90.9 11.6L90 9.9L89.9 7L87.3 5.2L85.7 3.2L84.1 3L82.6 1.3L82.3 0L79.9 2.3L78.4 1.9L76.8 5.5L73.6 6.1L72.7 8.9L70 9.2L67.7 7.4L57.2 7.4L57.7 6L57 6.5L56.7 7.4L19 7.4L19 20.2L13.1 20.2L13.1 47.1L10.7 46.7L9.2 47.2L7.8 47L6.6 48.1L7.1 49.3L6.7 50.9L5.1 52.3L5.4 53.3L3.6 53.9L3.4 54.5L3.8 54.6L3.7 55.9L4.6 56.5L3.8 57.3L2.4 57.9L1.6 59L2.9 61.6L2 62.7L0.7 63.4L0 64.9L0.8 66L2 65.8L2.3 65.2L4 66L3.3 67.3L4.1 68.8L3.9 69.6L4.9 69.4L4.5 72.1L5.8 73.5L6.7 73.3L7 74.5L6.4 76.6L7.1 77.7L8.8 79.1L11.1 82.7L11.3 84Z', w: 100, h: 85.1, b: [21.81, -23.81, 38.58, -9.53] },
SE: { d: 'M0.6 76.8L0.4 81.6L1.2 81L1.2 82.2L2.1 81.1L1.1 82.5L1.2 83.5L2.1 83.2L1.6 84.3L2.5 82.4L1.3 82.6L2.7 81.7L2 85.7L2.9 85.7L2.7 87.7L3.4 87.4L4.2 90.5L6.2 92.6L5.1 93.6L5.7 94.8L4.5 94.2L6.6 98L5.7 99.8L10.4 99.8L11.3 95.9L12.1 96.2L12 95.1L13.2 94.9L15.9 95.7L18 91.5L17.9 88.4L18.8 87.4L18.1 86.4L18.9 85.9L17.7 84.6L18.8 85.6L17.9 84.6L19 84.7L19.2 82.7L17.8 80.8L19.5 80.8L17 79.8L19.5 80L20 79L21.4 78.9L21.9 76.3L22.8 78.8L23 77.4L24.6 76.4L24.1 75.3L25.4 75.7L23.3 74.8L24.1 75L26.7 72.7L25.4 72.6L26 71.3L26.7 71.7L23.1 66.9L22.1 67.6L21.8 66.5L20.3 66.3L20.8 65L19.8 60.1L20.1 59.1L21.3 59.9L20.8 57.7L21.9 55.5L20.8 53.8L21.3 53.3L22.2 54.4L23 52.4L22 49.9L23.8 51.4L24.9 50.2L23.7 49.9L25.3 48.1L26 48.5L25.4 47.8L26.5 48.6L27.5 46.3L28.1 46.9L27.8 45.7L28.6 46.8L32.1 43.8L32.9 41.3L35.1 39.1L33 35.9L35 33.6L33.8 32.1L35.5 33.2L34.5 31.7L35.3 32.1L34.8 31.3L36.3 31.5L35.8 30.5L37 30.4L35.5 28.8L37.7 30.5L37.5 27.8L38.6 28.8L38.5 27.5L39.9 29.2L39.8 27.7L40.9 28.8L41.2 28L43.5 28.3L41.8 24.2L43 19.9L41.5 16.9L42.2 14.7L41 14.2L41.8 9.9L39.9 6.9L36.5 5.2L32.2 0.2L29.9 0.1L30.8 2.4L29.4 6.4L24.4 4.4L23.5 4.8L22.7 9.9L20.6 8.6L18.8 10.4L16.7 14.7L17.7 17.9L14.3 22.7L14.6 24.4L11.4 25.6L11.4 32.3L8.5 38L10 38.8L10.2 41L9.6 42.4L7.1 41.7L5.3 42.7L2.9 48L3.8 50L3.5 59.2L5.9 61.7L5.3 63.7L3.8 64.1L5.1 68.2L4.5 70.7L2.5 72L1.9 78.1L0.6 76.8Z', w: 43.5, h: 100, b: [11.09, -96.74, 24.15, -66.73] },
SG: { d: 'M43.7 0L35.4 2.1L33.3 6.3L27.1 6.3L29.2 8.3L25 12.5L27.1 16.7L25 14.6L22.9 16.7L25 12.5L20.8 12.5L20.8 10.4L22.9 12.5L27.1 10.4L25 4.2L25 6.3L22.9 6.3L25 4.2L20.8 4.2L16.7 8.3L12.5 8.3L10.4 18.8L4.2 25L6.3 27.1L4.2 27.1L2.1 39.6L0 39.6L2.1 41.7L0 41.7L0 43.8L2.1 41.7L0 43.8L2.1 50L0 52.1L0 50L2.1 54.2L0 52.1L2.1 52.1L4.2 43.8L2.1 43.8L8.3 39.6L6.3 33.3L4.2 33.3L6.3 31.3L6.3 33.3L8.3 31.3L8.3 35.4L10.4 33.3L8.3 33.3L10.4 37.5L12.5 37.5L12.5 33.3L12.5 35.4L14.6 33.3L14.6 35.4L16.7 33.3L20.8 35.4L20.8 33.3L20.8 37.5L18.8 37.5L20.8 39.6L16.7 39.6L16.7 41.7L14.6 39.6L18.8 37.5L14.6 37.5L10.4 41.7L12.5 47.9L14.6 47.9L16.7 41.7L18.8 45.8L20.8 45.8L18.8 41.7L22.9 43.8L25 43.8L22.9 41.7L27.1 41.7L25 37.5L20.8 37.5L22.9 35.4L20.8 33.3L25 35.4L25 33.3L22.9 35.4L25 33.3L25 27.1L22.9 27.1L25 27.1L25 35.4L31.3 35.4L29.2 35.4L29.2 31.3L33.3 39.6L31.3 37.5L29.2 39.6L33.3 41.7L35.4 39.6L39.6 43.8L41.7 41.7L39.6 41.7L43.7 43.8L47.9 43.8L43.7 43.8L47.9 41.7L50 43.8L47.9 41.7L50 43.8L54.2 39.6L52.1 37.5L50 39.6L54.2 35.4L52.1 33.3L54.2 31.3L54.2 35.4L56.2 35.4L54.2 35.4L54.2 39.6L60.4 35.4L66.7 35.4L66.7 33.3L72.9 33.3L70.8 29.2L72.9 33.3L77.1 29.2L77.1 33.3L87.5 31.3L87.5 29.2L85.4 31.3L85.4 27.1L89.6 25L81.2 16.7L70.8 18.8L64.6 14.6L60.4 18.8L62.5 18.8L60.4 22.9L60.4 16.7L64.6 16.7L64.6 12.5L60.4 10.4L58.3 16.7L56.2 16.7L58.3 10.4L52.1 10.4L50 2.1L45.8 2.1L45.8 0L43.7 2.1L43.7 0ZM93.7 8.3L89.6 8.3L89.6 10.4L87.5 8.3L87.5 14.6L89.6 16.7L93.7 14.6L89.6 16.7L91.7 18.8L95.8 18.8L97.9 12.5L97.9 14.6L93.7 14.6L100 12.5L93.7 8.3ZM68.7 8.3L66.7 10.4L70.8 14.6L79.2 12.5L79.2 10.4L68.7 10.4L68.7 8.3Z', w: 100, h: 54.2, b: [103.61, -1.47, 104.09, -1.21] },
SI: { d: 'M10.2 16.3L9.6 19.9L6.5 19.9L5.6 22.2L1.9 23.5L2.2 24.9L0 26.7L1.2 28.9L0.9 30.3L3.1 29.4L5.6 30.3L5.9 31.6L9 31.6L6.8 35.2L3.7 37L3.1 39.7L5 41.4L6.2 40.1L8.1 40.1L8.1 42.8L5.9 46.4L6.8 48.6L9 48.6L12.7 50.8L15.2 55.3L16.8 56.1L14.6 58.4L9.9 57.9L11.5 60.1L9 59.7L5.6 61L7.1 63.7L9.3 64.6L12.7 63.2L13.4 65L18.6 64.1L19.3 63.2L18 61.9L18.3 61L21.4 62.8L25.2 63.2L26.4 61.5L29.2 63.2L31.7 62.4L34.8 60.1L34.8 57L36 57L37 54.4L38.2 54.4L37.9 56.6L41 58.8L40.4 60.6L41.3 60.1L43.8 61.5L44.7 63.7L47.5 62.8L47.5 61L48.4 60.6L49.4 61.9L52.5 62.4L55.3 65.5L56.2 64.6L57.5 65L58.7 63.2L60.6 64.1L62.4 62.4L59.6 60.6L59 57L60.6 55.7L61.2 56.1L60.9 55.3L63 55.3L61.8 53.5L61.2 54.4L61.5 52.6L60.9 52.1L60.2 54.4L58.4 51.7L63 48.6L64.9 49L65.2 47.2L66.8 47.7L67.1 46.4L67.7 47.2L68.6 46.4L69.9 47.7L72.4 46.4L71.4 45.9L71.4 43.7L72.4 43.2L72 39.7L73 37.4L68.9 34.7L68.9 32.5L70.5 31.2L70.5 29.8L74.5 30.3L74.8 28L82 25.8L82.3 24.4L83.9 24.4L82.9 22.2L86 21.3L87.3 22.6L90.7 22.6L88.8 17.2L89.8 17.2L89.8 16.3L91 16.8L92.9 14.5L92.9 15.4L97.2 17.2L97.5 18.6L98.8 18.1L100 19L100 18.1L97.5 16.8L97.2 14L96.6 14.5L95.3 12.2L93.5 11.3L94.7 8.6L92.9 8.2L93.2 7.3L92.5 7.7L91 4.5L92.2 1.8L88.8 0L86.3 1.4L84.8 0.5L82.6 2.3L81.1 2.3L80.7 5.9L82.6 8.2L82.6 10L78 7.3L75.2 7.3L74.5 8.2L70.5 7.7L69.9 9.1L67.7 9.1L67.4 11.3L65.8 12.2L64.6 12.2L64.6 10.9L63 10L59 10.9L56.5 10.9L55.9 10L51.2 10.4L50 12.7L49.4 11.3L47.8 11.8L47.8 12.7L46.3 12.2L44.7 16.8L42.2 16.8L41 19L37.9 19.9L37 23.1L35.4 20.4L34.2 21.3L32.9 20.8L32.6 19.5L24.2 20.4L22 17.7L19.3 18.1L17.1 16.3L10.2 16.3Z', w: 100, h: 65.5, b: [13.38, -53.2, 16.6, -51.1] },
SK: { d: 'M5.7 42.3L8.5 42.6L10.5 44.4L10.8 45.7L12.2 46L12.9 47.5L13.8 47.3L15.3 49.1L22 48.8L26 49.6L28.2 48.6L28.7 49.1L32.1 48.8L33.4 47.3L35.4 47.3L33.6 46L34 43.6L33.4 43.4L34.7 42.9L34.8 41.3L37.5 41.3L38 40.5L42 41.3L42.9 40.2L45.3 40L46 40.5L47 37.1L48.6 36.1L49.8 37.4L50.9 36.9L51.9 37.6L51.6 38.7L52.6 37.9L53.5 38.2L53.5 39.5L54.7 38.2L56.6 37.9L57.7 35.8L58.9 36.1L59.2 35.3L60.6 35.8L64.1 29.8L64.1 28.5L70 27.4L70.2 28.2L71.4 27.9L72 29L73.9 28.7L74.9 29.8L76.5 28.5L78 29L78.2 27.9L80 27.9L80.3 27.1L82.1 29.2L83.3 29.2L84.1 32.4L85.2 33.4L87.1 34L87.5 32.9L92.5 32.7L93 27.1L96 24.8L96.7 19.7L97.4 19.5L97.4 18.2L98.6 16.3L99.7 16L100 13.9L97.2 13.6L96.5 12.3L93.9 12.3L93.7 11.2L93.4 11.8L90.6 10.4L90.8 8.8L89.5 7L88.3 7L87.5 5.9L86.1 6.7L83.6 4.3L80.1 5.4L77.4 4L76.3 5.6L74.9 4.6L73.9 4.8L73.5 5.6L74.4 6.4L73.3 6.4L72 8.3L70.4 7.8L70.4 7L69.2 7.2L68.1 5.1L66.9 5.6L65.9 5.1L65.7 6.2L63.4 5.1L62.4 5.9L61.7 5.4L60.8 5.6L60.8 7.2L58.9 7L58.4 8L57.5 8L56.8 11.5L56.3 11.5L54 9.9L53.3 11L51.2 11L52.1 8.8L51.6 5.4L50.5 5.9L48.8 5.4L49 4L47.7 4L47 1.1L46 0L44.3 1.1L44.3 1.9L42 2.4L41.8 4.3L40.4 5.6L39.4 5.1L38.3 5.9L37.3 5.6L36.9 2.4L34.5 2.4L33.6 3.2L30.1 2.7L28.7 5.4L27.4 5.6L27.7 6.4L26.8 7.8L23.5 8.6L22.1 14.7L20.9 15.8L19 15.8L18.1 18.4L16.6 18.2L15.3 20L12.2 21.3L11 20.3L9.2 21.3L8.5 20.3L6.3 19.5L4.5 22.1L3.7 22.4L1.7 26.9L2.1 28.7L0.5 30.3L0 32.7L1.4 34L1 34.5L2.3 35.8L2.6 38.2L4.4 39.5L4.2 41.8L5.7 42.3Z', w: 100, h: 49.6, b: [16.83, -57.3, 22.57, -54.46] },
SL: { d: 'M0 31.3L0.6 34.2L1.3 32.6L1.6 34.2L2.2 33.5L3.2 34.2L2.9 33.2L3.9 33.2L3.2 33.9L2.9 33.2L3.2 34.2L2.2 33.9L3.2 35.5L6.7 34.5L4.8 36.5L7.4 37.8L2.9 37.1L1.6 38.4L1.9 43.9L3.5 47.2L5.1 48.2L6.1 45.9L5.5 44.6L7.7 44.6L6.4 45.6L7.7 46.5L9 45.2L11.6 45.9L10.9 46.2L11.9 46.9L13.5 46.5L11.2 48.5L10.6 46.9L9 46.5L7.7 48.8L6.1 49.1L5.1 50.4L7.7 51.4L7.1 52L8.3 52.7L7.4 52.7L7.7 53.3L3.2 49.1L1 48.8L0.6 50.1L1 49.1L0 48.8L0.3 51.4L2.2 54L3.2 54L3.5 55.6L4.5 55.6L4.5 59.5L7.4 57.2L10 57.2L12.5 62.1L15.1 62.1L12.8 62.1L12.5 63.7L13.2 66.3L15.4 67.6L12.8 66.6L10.9 67.6L11.9 69.2L15.4 71.8L18.9 70.9L16.4 71.2L16.4 72.8L18.9 74.7L23.1 75.1L26 73.1L24.1 75.4L24.4 76.4L26.7 76L25.7 76.4L26.7 79.3L29.2 79.9L30.2 79.3L30.2 79.9L27.3 80.6L26 82.8L28.3 84.1L25 85.1L31.8 86.7L46.2 92.6L57.5 99.7L60.4 99.4L60.4 97.7L62.3 95.8L61.7 95.1L63.3 94.8L62.6 92.6L63.3 92.9L63.9 90.6L68.1 89L70.6 84.8L75.1 81.2L78.7 79.9L83.5 73.5L86.4 72.5L86.4 63.7L88 63.1L88.9 60.8L93.8 59.5L94.7 60.2L96.7 57.9L95.7 55.6L96.7 54.6L97.3 49.8L95.7 48.5L93.8 49.1L88.9 55L84.4 53.7L83.5 55.9L84.1 52.7L85.4 52.4L86.1 47.5L87.7 46.5L87.3 45.6L89.6 45.2L91.2 43L89.9 42L89.3 39.4L88 39.1L87 34.2L87.7 30.9L84.4 29.6L82.5 30L82.8 26.1L84.8 26.1L84.8 22.5L83.2 21.8L82.5 20.2L80 20.5L80.3 18.9L78.7 17.9L78.7 16.3L79.6 15.6L78 15L76.4 11.1L75.1 11.4L72.6 5.9L68.7 4.2L68.7 2L67.1 0L45 0L44.6 2L37.9 4.2L35 3.3L34.7 2.3L27.3 3.9L27 4.9L25.7 4.9L25 9.5L23.4 9.8L22.8 13L21.8 12.7L21.5 14.7L20.6 15L21.2 15.6L19.9 19.2L17.7 19.5L16.4 22.8L13.2 23.1L12.8 24.1L11.2 23.1L10.6 25.1L11.2 26.7L9.6 29L7.1 30L7.1 30.9L3.2 29.3L0 31.3Z', w: 97.3, h: 100, b: [-13.3, -10.05, -10.27, -6.94] },
SM: { d: 'M79.2 0L72 0L64.8 10L50.4 10L36 20L28.8 30L14.4 30L14.4 40L7.2 40L0 30L0 50L7.2 50L7.2 60L14.4 60L7.2 60L7.2 90L28.8 90L28.8 80L28.8 90L36 90L36 100L43.2 100L43.2 90L64.8 90L64.8 80L72 80L64.8 80L64.8 70L72 70L72 60L79.2 60L79.2 50L86.4 50L79.2 40L79.2 10L86.4 0L79.2 0Z', w: 86.4, h: 100, b: [12.4, -49.08, 12.52, -48.94] },
SN: { d: 'M12.6 60.8L12 66.9L13.6 66.3L12.3 66.6L12.3 68.8L15.4 66.9L19.3 68.8L16.3 68.4L16.2 69.2L15.4 67.8L13.9 69.4L12.6 69.2L12 70.9L13.1 72.7L18.6 72.4L23.6 70.4L30.3 71.2L37.7 66.9L72.2 67.6L72.3 70.4L73.9 70.6L75.1 69.1L77 71.1L79.4 71.1L80.4 72.2L83 71.9L83.7 73.2L88.2 71.2L89.6 71.7L91.1 70.9L93.4 72.1L97.7 70.9L99.5 71.7L100 70.7L98.4 69.2L99.2 68.8L98.5 66.8L99.8 66.3L99 64.3L100 62.1L99 62.3L96.1 55.6L93.7 54.8L92.2 56.6L91.3 55.5L91.6 54L88.8 51.5L88.3 49.8L90.3 48.3L90.6 46.5L89.3 45.5L90 42L89.2 40.3L86.2 38.5L85.9 36L87.2 34.6L86.9 33.6L82.7 30.8L81.6 28.1L78.2 26.9L76.5 24.7L74.9 24.2L76.1 23.6L74.1 22.4L73.9 20L72.2 20.4L71.8 18.5L69.6 18.2L69.9 16.7L68.6 15.7L67.3 10.6L65.2 10.3L65.5 9.1L62.1 10L62 8.6L59.5 9.8L59.2 8.3L57.6 7.9L57.6 5.9L53.6 2.4L52.9 3L51.8 0.8L50.5 0.7L41.9 1L40.9 0L40 1L39.3 0.2L39.2 1.9L34 2.5L33.2 1.7L32.7 2.9L30.7 2.7L29.6 3.7L26.9 2.9L23.8 3.5L23 2.2L20.2 2.9L18.9 7.3L16.5 10.6L15.7 16.3L10.2 25.2L6.5 29.9L0 32.8L1.5 34.3L1.6 32.9L2.9 32.8L5.8 34.5L11.8 43L12.5 46.3L13.4 44.8L15.2 45L12.8 45.8L13.8 47.6L15.5 47.6L16.7 46.1L16.7 50.5L15.2 51.3L16.3 51.5L15.9 52L33.3 52L34.8 49L37.5 49.3L39.8 48L43.5 48.8L45.1 51.3L48.7 51.1L51.6 54.1L57.3 52.1L58.9 52.5L60.4 55.5L59.5 56.5L55.2 57.1L54.9 58.1L51 58L47.7 55.8L46.3 56.1L41.1 53.8L39.5 51.8L37.7 53L36.4 55.6L28 56L28 59L13.6 59L12.6 60.8Z', w: 100, h: 73.2, b: [-17.53, -16.93, -11.35, -12.41] },
SO: { d: 'M14.4 7.4L14 8.2L12.2 10.2L12.4 10.8L13.2 11.4L13.5 13.1L14.7 14.1L15.4 15.6L16.5 15.9L16.7 17.5L17.5 18L18 19.1L19.3 19.5L21.9 22.1L43.7 29.5L51 29.5L36.4 44.2L29 52.1L21.9 51.8L18.5 52.8L15.4 54.2L14.9 54.5L14.2 56.1L13.5 56.7L11.4 57.2L7.9 57.5L7.1 58.2L7 58.7L6.7 58.8L2.6 64.8L0 67.4L0 94L4.1 99.6L4.1 100L6.2 97L5.8 96.3L6.4 96.9L6.6 96.7L6.4 96.7L7.1 95.8L7.1 95.3L7.6 95L8.1 94.2L7.8 94.1L8.7 93.7L9.9 92.1L10.9 91.2L10.8 91L11.4 90.7L11.6 90.1L18.8 82.6L26.6 76L36.5 70.3L39.2 67.5L41.5 65.5L50.7 55.3L53 51.4L55.5 48.3L57.7 44.7L58.7 42.6L59 40.9L60 38.4L63.1 33.8L64.3 31.4L64.3 30L66.5 28.1L66.8 27.1L68 25.5L68 24.8L68.4 24.3L68.8 22.9L70.2 21.6L70.3 20.7L71.5 19.3L71.8 18.7L71.4 18.3L71.4 17.7L72.1 14.8L72.1 12.4L73.9 11.7L74.8 11.7L74.8 11.9L75.6 12.1L75.9 11.5L74.2 10.8L74.6 11.6L73.2 11.7L73.1 11.4L73.6 11.3L73.7 10.6L74.1 10.5L73.8 9.8L73.7 7.4L74.2 6.2L73.5 6L73.4 4.9L73.7 3.6L74.8 2.3L75 1.1L73.2 0.9L71.4 0L69.7 0.6L68.9 2.3L66.2 3.6L62.5 3.9L61.5 4.8L60.5 4.8L58.2 5.6L55.8 4.9L53.9 5.2L52.2 6.4L48.6 6.7L47.7 6L46.7 6L44.9 6.8L44.4 7.5L43.2 7.9L41.4 9.2L39.6 9.7L38.9 9.7L38.3 9L36.2 9L35 8.3L32.5 9.9L31.5 9.9L30.9 10.6L28.5 11.8L27.3 11.7L25.9 11.9L23.9 11.6L20.2 8.5L18.3 5.9L18.1 5L18.3 4.6L17.9 4.8L17.3 4.5L16.9 3.9L16.4 4.1L14.4 7.4Z', w: 75.9, h: 100, b: [40.99, -12.08, 51.42, 1.66] },
SR: { d: 'M84 88.3L84.9 88.1L85.7 85.7L88.1 85L90.7 79L92.3 77.3L93.3 73L92.1 68.5L94.3 65.4L95.9 64.9L95.7 63.7L97.1 62L97.1 56.8L95.9 57.2L94.3 53.2L92.3 52.9L90.7 48.9L88.5 47.2L89.5 44.8L87.6 43.4L88.1 40L86.6 36.7L87.6 35L87.3 31.4L86.1 30.9L85.7 26.8L90.4 18.2L95.9 14.1L95.4 13.4L97.1 11.8L97.1 9.4L96.2 9.4L97.6 6.2L96.9 4.8L88.3 2.2L72.3 0L69.4 0.7L70.2 2.2L72.8 3.4L70.9 2.9L71.1 4.1L69.4 4.8L70.9 7.2L69.2 5.3L70.9 3.6L69.7 2.4L57.3 0.5L50.3 2.4L51.3 3.8L54.9 3.8L51.5 4.3L52.3 6L51.5 8.4L52 6L51.3 5.3L47 3.8L26.7 0.2L25.3 1L26 1.7L24.1 1.4L21.5 5L21.2 10.8L17.4 16.5L18.6 16.8L18.9 19.2L19.6 19.2L19.6 17.7L21.2 19.9L20.3 21.3L19.6 19.9L18.4 20.1L17.9 23L18.6 24.2L17.9 23.5L15.7 24.7L9.3 24.2L7.4 26.4L5.5 26.1L3.3 28.8L5.7 33.1L0 45.5L1 46.7L1 49.3L3.1 50.3L5.5 53.9L5.7 56.5L7.9 57.2L9.8 59.6L8.8 62.5L13.8 64.2L15.3 63L18.4 63L18.6 69.2L19.8 68.7L19.3 70.1L21 71.6L20 71.8L20.8 72.3L20.3 73.7L21.5 73.5L20.3 74.4L20.8 76.4L22.7 76.4L22.2 77.6L23.4 77.8L24.1 80.7L25.1 80.7L25.8 84.2L27 84L27.2 86.9L28.6 87.6L28.4 89.3L30.3 90L29.8 91.2L32.9 95.7L36.7 95.7L37.5 97.9L41.5 97.6L43.4 99L45.6 98.6L46.5 99.8L48.7 100L51.8 98.8L50.8 96.7L51.5 95.2L49.4 92.1L48 91.9L48.2 90.7L46.1 90L47.2 87.3L48.7 88.3L50.1 83.5L54.9 85.2L55.6 86.6L61.1 85.7L64.4 86.4L65.6 83.5L67.3 84.7L68.5 84.2L68.7 82.8L70.4 82.6L70.2 83.8L71.3 83.8L74.7 81.1L74 82.8L76.6 86.2L78.5 85.7L78.5 84.7L80.7 85L80.4 88.3L84 88.3Z', w: 97.6, h: 100, b: [-58.07, -6.03, -53.98, -1.84] },
SS: { d: 'M59.2 70.6L60.3 69L61.7 68.3L63 68.4L64.8 69.4L66.1 68.9L66.9 67.9L68 69.9L70 70.6L70 69.8L71.8 68.7L74.7 68.4L76.7 67.5L77.9 68.4L80.5 68.6L87.5 61.6L95.7 58.3L95.5 59.2L97 59.4L96.6 60.5L97.2 61.6L98 61.9L98.6 61.1L98.6 61.5L100 61.5L98.9 60.3L99.3 56L97.1 55.2L95 55.9L94.9 54.6L93.4 53.6L92.3 51L92 48.5L92.6 46.8L92 46.1L91.4 45.6L90.6 45.8L89.6 44.6L88.6 44.5L88.2 43.1L87.1 43.1L86.3 42.5L86 41.5L84.6 40.6L84.7 39.6L83.6 38.1L81.8 36.9L79.9 36.4L79 36.8L76.4 35.6L77.1 33.9L77.9 33.4L78.2 30.9L81.5 30.6L82.7 31.4L85.6 29.5L85.3 22.3L83.5 22.3L84.3 19L84.2 17L80.6 13L78.4 11.9L77.5 5L78.7 0.2L74.3 0L74.4 2.4L69.2 2.4L71.5 4.3L71.5 8.7L72.2 9.8L68.3 13L66.7 15.9L62.7 20.2L59.1 20.6L56 18.1L52 15.9L48.8 17.6L48.8 20.3L45 20.3L42.7 22.4L42.4 23.5L36.8 23.5L35.7 21.4L29.6 21.3L26.1 22.4L22.9 21.3L22.1 18.9L19.9 16.8L19.9 15.1L18.5 14.7L13.3 15.7L12.7 16.9L12.9 18.1L11 20L8.5 19.5L5.5 20.4L5.3 21L2.1 20.9L1.5 21.8L1.8 24.1L0.4 24.9L0 26.1L0.6 26.7L1.2 26.4L0.6 28.6L6.6 28.7L6.6 29.8L5.8 30.8L6 31.9L11.4 33L12.4 34.7L14.3 35.5L14.9 37.1L13.9 37.8L14.1 38.6L16.1 40.2L20.8 42.4L21.3 43.9L23.8 45.3L22.7 46.8L24.8 48.9L24.1 49.8L24.5 49.6L25 50.3L27 50.6L27.3 51.4L29.8 52.4L30.7 53.8L30.3 55.4L30.9 56.9L32.1 57.9L32.5 59.2L33.7 59.4L34.1 60.3L34.7 60.3L34.8 61.7L35.5 62.3L36.9 62.2L37.1 63.2L37.6 63.1L38 63.8L39.2 63.7L39.9 64.4L42.6 62.1L43.1 62.7L44.6 62.6L46.3 63.9L48 62.4L48.4 61L49.7 61.4L51.1 62.1L50.8 63.6L52.2 64.2L52 64.7L52.9 65.6L53.7 65.6L54.2 67.2L56.7 67.7L56.9 69.6L58.3 69.6L58.7 69.2L59.2 70.6Z', w: 100, h: 70.7, b: [23.44, -12.33, 35.95, -3.49] },
ST: { d: 'M11.9 76.8L8.9 76.8L8.9 77.4L8.3 77.4L7.1 79.2L4.8 79.8L4.8 80.4L4.2 80.4L3 82.1L1.8 82.1L1.8 83.9L1.2 83.9L1.2 85.1L0.6 85.1L0 86.3L0 88.7L0.6 88.7L0.6 89.3L0 89.3L0.6 89.3L0.6 90.5L1.2 90.5L1.2 92.9L1.8 92.9L1.2 92.9L1.2 93.5L2.4 93.5L1.8 94.6L3 94.6L3.6 95.2L3 95.8L3.6 95.8L3.6 96.4L3 96.4L3.6 96.4L3.6 97L3 97L3.6 97L3 98.2L2.4 98.2L3 98.2L3 100L3.6 100L3.6 99.4L3.6 100L3.6 99.4L4.2 99.4L4.2 100L4.2 99.4L4.8 99.4L4.2 98.2L4.8 98.2L5.4 99.4L5.4 98.8L6 98.8L6 97.6L6.5 97.6L6 97.6L6.5 97L6 97L6.5 95.8L7.1 95.8L7.1 96.4L7.1 95.8L7.1 96.4L8.3 96.4L8.3 95.8L8.3 96.4L8.9 96.4L9.5 95.2L10.7 95.2L10.7 94.6L11.3 94.6L11.3 93.5L11.9 93.5L11.3 92.9L12.5 92.9L12.5 91.7L12.5 92.3L13.7 90.5L14.3 90.5L14.3 89.3L15.5 88.7L15.5 89.3L16.7 87.5L17.3 87.5L16.7 86.9L17.3 86.9L17.3 85.7L16.7 85.7L17.3 85.7L17.3 83.9L17.9 83.9L17.9 83.3L17.3 83.3L17.3 82.1L16.7 82.1L16.7 80.4L16.7 81L15.5 81L15.5 79.8L16.1 79.8L14.9 79.8L15.5 78.6L14.3 78.6L13.1 76.8L11.9 76.8ZM56.5 0.6L55.9 0.6L55.9 0L55.3 1.2L54.2 1.2L54.2 3.6L53.6 3.6L54.2 4.2L54.2 5.4L53.6 5.4L54.2 5.4L53.6 5.4L53.6 6L52.4 6L52.4 5.4L51.8 5.4L51.8 6L52.4 6L52.4 6.5L51.8 6.5L52.4 7.1L52.4 8.9L53.6 8.3L53.6 8.9L54.8 8.9L54.8 9.5L55.9 9.5L55.9 10.1L55.9 8.9L56.5 8.9L56.5 8.3L57.1 8.3L57.1 8.9L57.1 8.3L57.7 8.3L57.1 8.3L57.7 7.7L57.7 6.5L57.1 6.5L57.1 6L58.3 5.4L58.3 4.8L59.5 4.8L59.5 3.6L59.5 4.2L58.9 4.2L59.5 3.6L58.3 3.6L58.3 3L57.1 3.6L57.7 2.4L58.9 2.4L59.5 1.2L58.9 1.2L58.9 0.6L58.3 0.6L58.3 1.2L57.7 1.2L57.7 0.6L57.7 1.2L56.5 0.6Z', w: 59.5, h: 100, b: [6.46, -1.7, 7.46, -0.02] },
SV: { d: 'M31.8 1.3L30.2 0L28.9 1.7L26.9 0.9L25.2 2.1L25.2 3L24.4 3L24.4 1.3L23.1 1.3L22.7 3.4L23.6 4.3L22.3 4.3L22.3 6L23.6 5.5L25.2 9.8L19 11.1L15.7 16.2L16.5 17.9L14.5 17.9L14 17.1L13.2 17.1L13.6 16.6L11.2 16.6L11.2 17.5L9.5 17.5L7.9 20L7.4 19.6L6.6 20.9L6.2 20.5L5.8 21.3L5.4 20.9L5 21.7L4.1 21.7L3.7 23.9L2.5 23.9L0.4 27.3L0.4 28.5L1.2 28.5L1.7 29.8L0 30.2L3.7 32L4.1 31.5L3.7 32L11.2 35.8L13.2 39.6L19.8 39.2L24.8 40.9L27.3 40.5L35.1 41.3L43.4 45.6L43.8 46.4L47.1 47.3L50.8 49.4L51.2 48.9L50 48.5L51.2 48.5L51.7 49.8L54.1 51.5L54.1 50.2L55.4 49.8L54.1 50.6L54.5 51.5L62.4 52.8L68.2 54.9L69 54.9L67.4 53.2L66.5 53.6L65.7 52.8L62.8 52.3L65.3 52.8L63.6 50.6L61.2 51.5L58.3 50.6L61.6 50.6L62 49.8L61.6 50.2L60.3 49.4L62 49.8L61.6 48.5L61.6 49.4L62.8 50.2L65.3 50.2L66.1 51.9L67.4 51.9L67.8 50.6L68.6 50.6L68.2 51.1L69.4 51.5L69.8 52.8L71.9 52.3L72.3 53.2L74 53.6L72.7 51.9L73.1 51.5L74.8 54L74 53.6L73.1 54.5L82.6 54.9L85.5 54L85.1 54.5L86.8 54.9L92.1 55.3L91.3 54L91.7 53.2L90.5 54L90.9 53.2L94.6 51.9L96.7 50.2L97.1 48.9L95.5 48.1L95.5 47.3L94.2 47.3L93 45.6L94.6 43L95 44.3L96.7 44.3L96.7 43.4L99.2 42.6L99.6 43L99.6 40L97.1 40L96.7 37.9L97.5 37.5L97.5 36.2L98.3 36.2L97.9 32.4L99.2 31.5L98.8 29.8L100 28.5L100 26.8L98.3 26.4L98.3 25.1L96.3 23.9L96.3 22.6L95 22.6L94.2 23.9L91.3 23.4L91.3 24.3L89.7 23.4L87.6 24.7L87.6 23.9L85.1 21.3L85.1 19.6L83.1 19.2L81 20L79.8 19.2L78.1 19.2L78.9 21.3L76.9 21.7L76.9 23L75.2 23L74.8 23.9L73.6 23.4L73.6 24.7L72.7 24.7L72.3 23.9L70.7 23.9L68.6 25.6L67.4 24.7L66.9 20L64.5 20L62.4 18.3L59.9 18.8L59.9 17.5L57.4 16.6L57.9 14.1L56.2 14.9L53.7 14.9L52.5 11.5L50.8 11.1L50.8 10.2L47.9 10.2L46.7 7.7L45.5 7.7L45.9 6L45 5.5L45.5 5.1L43 4.3L43 2.1L41.7 2.1L40.5 4.3L34.3 1.3L31.8 1.3Z', w: 100, h: 55.3, b: [-90.13, -14.61, -87.71, -13.27] },
SY: { d: 'M47.1 71.3L74.4 54.5L79.4 53.1L80 52.4L81.4 48.6L83.1 46.3L82.9 37.6L83.7 33.7L85.3 31L85.1 27.1L83.5 23.1L83.8 17.8L85.6 14.7L92 13.3L100 4.4L99.6 4.4L99.1 2.8L99.4 1.5L98.7 0.7L97.5 0.7L97.8 0L95 2.6L89.1 3.7L87.6 4.4L82.6 4.8L82 4.1L76.1 3.5L72.9 5.4L71 5.5L70.3 6.7L67.5 8.3L65.8 8.5L65.2 9.2L62.6 10.3L54.1 12L51.3 11.3L46.2 11.3L43.6 8.7L39.3 7.4L37.4 7.8L35.9 8.5L35.9 9L32.4 10.5L30.3 10.5L28.1 11.8L27.8 12.7L24 11.8L22.4 12L22.1 12.7L21.1 12L21.5 10.9L20.6 10.7L20.8 10.1L15.9 9L15 10.5L15.3 11.4L14.4 12.2L14.7 13.6L14 15.5L15 17.1L14.9 18.2L15.8 18L16.3 19.7L15.2 19.9L15.3 20.4L14.3 19.9L14.1 20.2L13.4 19.9L13 20.6L11.6 20.2L11.6 24.1L11.2 24.4L10.3 24.1L10.3 25.1L9 25.1L8.4 27.7L7.7 26.6L6 26.4L6.2 25.3L4.7 25.5L3.8 27L3.1 26.8L3.7 29L2.7 30L2.5 31.7L1.8 31.7L2.7 32.6L2.7 33.3L3.4 33.3L4.9 35.1L5.3 38.9L4.4 40.2L4.6 42.1L4 43.9L5.4 48.4L5.7 49L5.7 48.6L10.5 49L10.5 48.2L11.2 47.9L11.6 49L12.8 48.8L12.8 49.7L11.9 49.3L12.1 50.4L10.9 50.9L12.7 51.1L12.8 52L14.3 52.9L13.8 53.6L14.3 54.7L14.9 54.7L14.6 56.1L15.2 56.8L13.5 58.4L13.4 59.3L12.1 59.3L11.9 60.2L10.2 61.6L11.5 62.5L11.8 63.4L9.6 62.9L9.4 63.4L8 63L6.9 63.6L5.2 66.2L5.2 66.9L6.6 67.5L6.3 68.7L5.6 68.3L5 68.9L5.3 69.8L3.7 70.6L3.4 71.7L1.8 72.1L1.8 72.6L0.4 73.1L0.4 73.6L1.2 73.5L1.2 75.2L0.7 77L0 77.5L0.6 78.2L0.3 79.8L1 81.2L1 82.2L0.6 82.2L0.3 83.3L1.2 83.5L3.1 82.1L2.9 82.4L3.8 82.8L5.2 82.8L5.4 83.8L6.5 83.8L6.3 84.7L7.2 86.4L9 86.1L11.9 88.7L18.4 89.9L47.1 71.3Z', w: 100, h: 89.9, b: [35.59, -40.28, 42.38, -34.17] },
SZ: { d: 'M66.5 14.3L66 14.3L66 14.9L64.9 14.9L64.9 15.5L63.7 15.5L63.2 16.2L62.1 16.2L62.1 16.8L61 16.8L61 17.4L59.8 17.4L59.8 16.8L59.3 16.8L58.7 16.2L58.2 16.2L58.2 15.5L57.6 15.5L57 14.9L56.5 14.9L55.9 14.3L55.4 14.3L54.2 13L53.7 13L53.7 12.4L52.6 12.4L52.6 11.8L52 11.8L52 11.2L50.9 11.2L50.9 10.6L49.8 10.6L49.8 9.9L49.2 9.9L48.7 9.3L48.1 9.3L48.1 8.7L47.5 8.7L47 8.1L46.4 8.1L46.4 7.5L45.9 7.5L45.3 6.8L44.7 6.8L44.7 6.2L43.6 6.2L43.6 5.6L43.1 5.6L42.5 5L41.9 5L41.9 4.3L40.8 4.3L40.8 3.7L40.3 3.7L39.7 3.1L39.1 3.1L39.1 2.5L38.6 2.5L38 1.9L36.9 1.2L36.3 0.6L35.8 0.6L35.2 0L34.1 0L32.4 0.6L31.9 0.6L30.8 1.2L29.6 1.2L27.4 3.7L27.4 4.3L26.3 5.6L26.3 6.2L25.7 6.2L25.2 6.8L24 7.5L21.8 9.9L20.7 10.6L19 12.4L18.5 14.9L17.3 17.4L17.3 18L15.7 21.1L15.7 21.8L15.1 23L14.5 23.6L14.5 24.2L14 25.5L12.9 26.7L12.3 28.6L11.7 29.2L11.7 29.9L10.6 31.1L10.6 31.7L10.1 32.3L10.1 33L5.6 37.3L5.6 38L5 39.2L4.5 39.2L4.5 40.5L3.4 41.7L3.4 42.3L2.8 42.3L2.8 43L2.2 43L2.2 43.6L1.7 44.2L1.7 44.8L1.1 45.5L1.1 46.7L0.6 46.7L0.6 51.1L0 51.7L0 64.2L0.6 68.6L1.1 69.9L1.7 69.9L2.2 69.2L2.8 69.2L2.8 68.6L4.5 68.6L4.5 68L5 68L5.6 67.3L5.6 66.1L5.6 67.3L6.2 70.5L6.7 70.5L6.7 71.1L7.3 71.1L7.3 71.7L7.8 71.7L7.8 72.4L8.4 72.4L8.4 73L9.5 73L9.5 73.6L10.1 73.6L10.1 74.2L10.6 74.9L10.6 77.4L10.1 77.4L9.5 79.9L11.2 82.4L12.9 84.3L15.1 86.2L20.1 92.5L39.1 99.4L53.7 100L66.5 100L66.5 91.2L66 91.2L66 87.4L66.5 86.8L66.5 81.1L67.1 80.5L67.1 78L67.7 77.4L67.7 73L68.2 71.1L68.2 68L69.9 68L69.9 68.6L71 68.6L71 68L72.7 68L72.7 67.3L73.3 67.3L73.3 68L73.8 68L73.8 69.2L74.4 69.2L74.4 69.9L74.9 69.9L74.9 50.5L73.8 46.1L72.1 43L72.1 39.8L71.6 36.7L72.7 30.5L73.8 27.4L72.7 21.8L72.7 18L69.3 17.4L68.2 16.8L67.7 17.4L66.5 14.3Z', w: 74.9, h: 100, b: [30.79, 26.63, 32.13, 28.42] },
TD: { d: 'M12.1 99.5L13.5 99.5L14 100L15.1 99.7L15.6 99.1L17.5 98.6L17.8 97.8L18.1 97.9L18.7 97.3L18.7 98L19.1 98.1L19.3 98.8L20.3 99.4L20.6 99.3L20.5 98.9L21.7 98.7L21.7 98.4L22.1 98.6L22.1 98.3L22.7 98.1L23 97.6L23.9 97.2L24.2 97.4L25.1 96.7L26.8 96.9L27.4 96.5L30.8 96.3L31.1 95.4L32.2 94.8L33.9 92.5L32.3 91.5L32.4 91.1L33.6 90.4L37 90.4L38.8 90.1L39.7 89.6L40.7 89.8L41.8 89.6L41.9 89.3L42.3 89.2L42.1 88.8L42.3 88.5L43.1 88.7L43.2 88.1L44.1 87.9L44.4 87.4L44.7 87.4L45.2 86.6L45.1 86L45.3 86.1L45.3 85.9L45.9 85.7L46.7 84.5L47.3 84.7L48.3 83.1L49.2 83L49.8 81.9L49.4 81.4L50.1 79.3L51.4 78.7L52.2 79L54.3 78.2L56.4 78.7L57 76.5L56.7 76.4L56.8 75.7L55.9 75.9L54.5 74.5L55 71.8L54 72L54.2 71.2L53.8 69.9L53.4 69.6L54 68.4L52.4 67.6L52.1 68.1L50.8 68.4L50 67.3L50.7 65.7L51.2 65.2L52 65.1L52.9 63.9L52.5 63.3L52.5 62.6L51.6 61.3L52.5 60.2L53.8 59.6L54.6 58.8L53.7 58.1L53.8 56.9L53.4 56.8L53.6 56.2L55.5 55.6L55.2 54.5L55.7 54.4L55.7 53.9L56 53.5L56.8 53.1L56.7 52.7L57.2 52.4L57.2 51.5L56.7 50.3L57.9 49.3L59.3 49.4L60.8 48.9L63.2 49.4L63.2 25.5L42 13.8L15.2 0L9.2 2.9L10.4 9.4L10.4 12.7L10.9 13L12.2 16.1L11.7 16.7L11.7 17.2L12.3 17.6L12.9 18.8L15.2 20L13.7 22.5L12.7 29.8L12.4 42.8L5.5 49.2L2.4 53.4L1.9 54.5L2 55.5L1.2 56L1.4 56.5L0 57.1L1 61.7L3.7 65.6L6 65.6L6.5 66.5L6.5 67.5L7.4 67.8L7.4 68.2L7.7 68L7.7 68.3L8.2 68.4L8.2 69.4L8.5 69.4L8.6 69.9L8.5 71.3L8.9 71.6L9.4 71.6L9.4 72.1L9.7 72.2L9.5 72.4L9.5 73.2L9.7 73.1L9.9 73.5L9.7 73.7L9.8 73.9L9.5 74L9.8 74.8L10.1 74.9L9.5 75.8L9.8 77.9L9.5 78.4L9.7 78.8L9.6 79.5L10.1 80.4L10 81.2L10.6 81.5L11 82.6L12.1 83.7L13.3 84.5L11.9 84.8L10.4 84.5L7.9 84.8L4.5 84.4L3.1 86.6L4.8 88.4L5 88.3L5.5 88.8L5.3 89.3L6.2 90.2L10.1 92.9L11.9 97.3L12.2 97.8L12.7 97.9L12.6 99.1L12.1 99.5Z', w: 63.2, h: 100, b: [13.47, -24.13, 24, -7.46] },
TG: { d: 'M0 0L0 0.8L1.2 1.2L2.8 0.6L3.3 2L3.3 3.2L2.6 3.6L2.2 5.2L2.4 6.4L1 8.4L1.6 10.2L2.8 10.4L3.9 11.6L5.5 12.2L6.5 14.8L7.1 14.2L7.3 14.6L8.4 14.4L8.4 15.4L9.4 16.2L9 16.6L10.6 16.6L9.8 17.6L9.6 21L11 21.8L10.8 22.4L9.8 22.2L10.4 24L9.6 24.4L9.8 26L9 28L10.2 29.8L10.2 31.2L7.5 31.2L7.5 32.4L8.4 32.2L8.8 32.8L7.3 33L7.3 33.6L7.7 34.2L8.4 34.2L9.4 33.8L9.8 32.8L11.8 32.8L12.8 33.4L12.6 34L14 34.5L13.8 36.5L12.8 37.3L13.2 38.7L12.2 39.7L12.4 40.5L11.8 41.1L11.8 42.3L12.4 43.7L13 43.9L13.2 45.3L12.4 46.7L11.4 46.5L11.4 47.1L10.4 46.7L10.6 47.7L10 47.5L12 50.9L12.4 50.7L12.8 51.5L13.2 51.3L14 52.3L15.5 52.8L15.5 54.2L16.3 54.4L17.1 55.8L17.1 56.8L15.3 57.4L15.1 58.2L14.3 58.4L14.7 59.8L14.1 60.8L15.1 65.2L15.1 67.3L14.7 67.3L15.1 68.5L14.3 68.3L14.3 70.1L13 70.7L13 73.5L14 74.7L15.1 74.3L15.7 76.3L14.3 80L14.7 80.2L14.5 82.4L13 83L13 83.6L14 84L13.2 85.8L14 85.8L14.1 87.1L15.5 87.5L15.3 89.7L15.7 90.3L17.3 90.7L17.5 91.1L16.7 91.7L17.5 93.5L19.3 94.3L20.2 95.7L22.4 95.5L24 98.8L26.3 98.8L26.3 100L29.7 98.6L38.1 96.6L37.5 93.3L37.1 93.5L36.7 92.3L36 92.3L36.4 91.5L34.2 89.9L34.4 88.9L33.6 88.5L34.8 87.1L34 86.4L34.4 84.2L33.8 84.2L33.4 83L33.4 82.4L34.8 82.4L35.2 70.9L34.8 69.7L34.6 54.6L35.2 53L34.6 51.9L34.8 42.5L33.4 39.3L30.5 36.1L30.3 33.2L29.9 32.8L29.5 33.2L29.1 31.8L29.7 29.6L29.3 23L18.1 15.2L18.7 11.2L18.5 8.4L19.8 6.8L20.8 2.8L15.7 2.8L12.6 4.2L12.8 2.6L0 0Z', w: 38.3, h: 100, b: [-0.14, -11.21, 1.81, -6.12] },
TH: { d: 'M51.4 42L53.5 40.7L54.1 37.7L53.1 35.1L54.2 33.1L52.6 32.1L52.7 30.6L50.3 30.1L48.4 27.2L48.8 21.2L43.3 14.8L39 14.1L37.4 17.1L35 18.5L31 15.6L24.8 20.7L23.7 20.1L23.8 18.7L25.1 16.8L24.2 14.1L26.3 9.9L25.2 7.6L25.8 6.3L20.7 6.6L20 5L21.2 2.1L19.7 0.6L18.4 1.6L17.1 0L16.1 1L13.9 0.6L14.4 2.2L11.3 2.6L10.8 5.1L5.9 5.5L4.5 4.6L2.9 7.4L3.3 9.6L2.2 10.6L2.8 13.1L0 13.3L1 15.2L1.9 15L2.7 19L7.8 24.6L7.3 25.8L8.7 28.9L10.3 28.1L9.7 30L8 30.4L8 35.2L6.3 35.5L5.5 36.9L8.2 42.1L11.6 45L12.2 49.7L11.5 50.6L13.7 53.8L13.9 56.8L15.2 58.9L12.4 63.7L9.4 66.5L5.7 80.8L6.8 83.4L8.4 81.6L9.3 84.1L11.6 84.5L11.1 86.2L11.8 85.7L12.6 86.4L13.5 88.9L14.8 88.4L14.3 89.3L15.8 89.9L15.4 91.8L18.2 94.7L18.8 92.6L19.7 93.9L22.2 94.1L22.9 95.9L24.6 95.8L23.8 98.8L24.8 100L27.6 97.9L29.3 99.2L31 95.9L27.4 91.8L23.9 91.8L21.3 89.4L20.1 89.8L19.9 87.6L19.3 88.6L18.6 87.5L18.8 85.7L20.1 89.1L21.2 89.5L18.9 81.4L17.1 80.3L16.5 76L15.3 75.4L12.5 76.2L12.9 75.1L11.8 72.5L12.5 69.6L11.8 68.7L12.7 68.6L12.4 67.5L14.2 65.1L14.1 63.3L17.5 56.5L17.1 53.2L18.1 50.7L17.1 48.9L21.2 46.9L23.9 47.7L22.9 53.4L26.7 53.8L29.1 53L32 55.6L31.9 54.8L32.8 55.2L32.4 56.5L34.2 57.4L34.1 56.2L34.7 56.5L36.4 60.1L35 56.6L35.6 54.9L33.8 53.2L32.7 47.3L34.5 46.9L36.6 43L38.1 42L48.8 41.3L50.4 42.9L51.4 42Z', w: 54.2, h: 100, b: [97.34, -20.92, 105.64, -5.62] },
TJ: { d: 'M96.7 62.9L99.7 61.5L100 59.7L97.2 57.6L96.8 55.7L98.2 54L96.9 52.9L97.1 50.1L95.8 49.2L95.5 47.2L94.2 46.7L94.2 43.5L87.7 40L85.4 42L82.7 40.5L81.4 36.1L83.4 34.6L79.4 29.5L79.8 28.3L78.9 27.8L80 26.3L77.5 26.7L75 28.3L65.8 27.8L63.6 28.8L62.6 31.1L60.3 28L57.1 29.7L57 26.5L54 26.5L54.2 24.7L53.1 23.7L50.6 25.7L48.1 25.5L47.6 27.3L46.1 26.8L45.8 27.7L43.9 27.5L43.8 26L41 23.9L38.8 24.2L38 25.3L35.7 24.4L32.3 25.3L30.2 24.2L25.9 25.5L24.5 19.7L25.6 19L25.6 17.2L26.8 18.9L28 18.7L28 15.5L30.1 16.2L35 14L38.7 16.3L41 16.7L41.5 17.5L40.5 18.7L42.5 17.7L42.5 16L43.9 15.7L44.3 14.5L46.6 14.5L46.7 13.8L42.1 14.5L41.2 11.5L38.5 10.8L38.3 9.8L40.3 9.1L44.3 5.3L42.6 4.4L42.6 3.6L43.4 3.9L40.2 0L38.9 0.5L38.7 2.5L34.1 5.3L31.6 5.6L30.6 6.9L28.2 4.6L26.1 4.2L25.7 7.6L23.9 8.3L25.6 11.7L25.2 12.8L24.5 12.5L25.2 14.3L21.3 13.7L15.4 15L17.2 16L21.6 14.8L21 16.7L19.2 16.3L20.5 18.7L20 19.9L16.5 20.2L15.5 25.2L9.9 24.8L4.6 23.2L0.6 25.3L1.9 27.8L1.5 29L0 29L0 30.3L4.4 31.6L4.6 34.1L9.5 33.6L11 35.1L10.6 36.9L9.3 37.1L10.1 38.4L9.2 38.7L10 42.6L9.3 44.1L10.2 43.8L12.3 45.3L13.6 47.2L12 51.8L10 51.8L6.1 59.5L5.6 64.8L8.7 67.9L11.9 66.5L12.3 64.8L16.8 63.2L17.2 62.1L19 62.8L19.3 61.5L20.4 62.3L19.8 61.3L24.5 65.2L25.2 64.8L27 63.1L26 60L28 57.1L31.8 57.4L33 56.6L33.5 57.6L36.2 58.1L37.9 55.5L36.4 51.3L37.6 51.4L37.4 50.8L40.6 48.5L42.6 43.8L45.2 42.8L46 43.5L46.6 42.5L47.5 44L48.3 43.6L51.6 46.1L50.2 51.8L54.4 51.6L52.4 65.8L55.4 71.9L57.7 71.7L63.6 66.9L68.4 66.3L70 62.9L76.4 59.1L82.5 59.5L82.3 61.1L80.4 62.1L82.1 63.1L87.7 61.3L88.3 59.9L91.3 59.7L95.5 60.8L96.7 62.9Z', w: 100, h: 71.9, b: [67.34, -45.08, 75.15, -39.46] },
TL: { d: 'M32.2 35.4L33.4 34.2L35.9 33.8L37.4 32L38.3 32L38.7 31L41.4 29.2L43.3 29.2L45.1 27.9L46.9 27.6L47.2 27L50.9 27.3L50.3 26.7L51.2 27L53.4 25.8L54.9 25.8L55.2 25.1L58.3 25.4L64.7 20.8L69.3 20.8L72.1 19.5L74.8 19.5L78.2 14.9L78.8 15.2L81 13.6L86.2 13.6L86.8 12.7L89.6 11.2L91.4 11.5L91.4 10.9L93.9 8.1L96.6 6.5L99.4 3.7L100 3.7L100 2.2L97.9 1.9L96.3 0.3L94.8 1.6L93.3 1.6L92.3 0.6L90.2 0L84.4 3.1L83.1 3.1L82.5 2.5L77.9 5L76.1 4.7L75.8 4L74.2 3.7L73 2.8L69.6 4L68.4 5.3L65 5.6L65.3 6.2L65 5.6L63.8 5.6L62.9 6.2L60.4 5.9L59.8 6.5L60.1 5.6L59.5 5L58.9 5.3L55.5 4.7L54.6 5.6L52.8 5.9L52.5 6.5L51.2 6.2L50.3 6.8L48.2 6.2L48.2 7.1L45.4 6.8L45.1 7.4L44.2 7.1L44.2 7.8L41.4 7.4L38.7 8.7L35 9L33.1 9.9L32.5 11.2L32.2 15.2L29.4 16.8L29.1 18.9L27.9 19.5L27.9 20.5L27.3 20.8L27.6 22.7L28.8 23.3L30.7 22L31.6 22L32.5 21.1L32.5 20.2L35.3 22L34.7 25.4L35.6 26.1L34.7 26.7L33.4 26.4L32.8 27.3L32.2 27.3L31.6 26.4L29.8 26.4L28.8 27L28.8 28.9L29.4 29.2L29.1 30.1L31 31L31 32.3L31.9 32.9L32.2 35.4ZM13.5 26.4L11.3 26.4L10.4 27.3L8.3 27.3L6.4 28.6L5.8 28.2L4.3 28.9L4 30.1L0 31.7L1.8 33.8L2.8 33.8L2.8 34.5L3.4 34.5L4.9 32.6L6.1 32.6L7.7 35.1L7.4 36.6L8.6 36.6L9.2 36L9.5 36.3L9.5 34.2L10.1 34.2L9.8 33.5L10.4 33.5L10.4 32.3L12.9 30.7L12.6 29.5L13.2 28.9L13.5 26.4Z', w: 100, h: 36.6, b: [124.04, 8.35, 127.3, 9.54] },
TM: { d: 'M98.9 50L98.8 46.3L100 45L99.6 44.3L97.8 44L96.8 42.8L94.5 41.7L91.8 41.6L83.3 35.3L82.3 35.6L81.3 35L70.9 27.1L70 26.2L69.4 22.2L67.9 21.2L67.2 19.6L66.9 17.1L66.2 15.9L63.2 14.2L62.3 15.6L61.5 15.6L60.2 14.4L60.2 14.9L57.7 14.5L56.4 15L54.2 13.6L53.4 12.7L54.2 11.2L53.2 10L53.5 9.4L54.9 10.1L55.3 9.8L52.4 7.7L53.2 7.5L53.4 6.7L52.7 6.3L53.3 6L52.9 5.2L52.2 4.6L51.5 5L48.9 4.9L47.2 2.6L46 2.6L43.3 0L43.1 1.4L40.7 1.1L40 1.6L41 2.3L42.5 4.9L41.7 4.9L41.2 3.3L39.8 3.4L39.9 2.9L39.3 2.5L38.7 3.5L38.2 3.5L38.6 4.3L38.2 5.6L37.8 5.3L37.9 6L33.9 6.3L32.7 8.4L31.9 8.4L31.7 10.7L32.2 13L33.1 13.5L32.3 14L32.3 14.7L24.1 13.9L22.2 14.6L21 14.4L18.7 11.4L17.4 8.3L12.4 4L7.9 4.9L3.9 6.4L0 9.9L0.9 10.5L0.8 12L2.2 13.3L2.9 16.5L3.5 17L2.7 19.5L2.9 19.9L2 21.5L2.2 26.1L3.4 27.5L3 25.9L7 26.4L7.4 26.9L6.7 28.2L7 28.1L7 29.1L7.6 29.4L7.4 28.7L7.9 29.7L8.2 29.4L8 30L7.7 29.7L7.9 30.2L8.3 29.7L8.9 30.1L7.5 30.7L7.9 30.1L7.4 29.6L7.4 30.4L5.5 30.3L6.2 29.1L5.8 29L4.6 31.2L5.1 33.3L5 31.8L7.1 32L7.6 33L7.9 32.6L7.9 33.6L8.1 33.2L8.7 34.5L8.8 33.4L9 35L9 34.6L10.8 35.8L9.7 39.2L9.5 45.9L10.2 50L12.6 50.3L14.9 49L15.6 49.2L16.5 48.5L16.8 46.4L21.2 43.3L23.6 43.1L26.7 43.6L27.3 43.5L27.1 42.6L27.8 41.9L29.2 42.2L30.2 41.6L32.4 42.6L33.5 41.7L34.6 43.4L34.4 44.4L35 44.8L37.1 44.9L40.1 46.1L40.5 47.1L41.6 47.5L42.8 46.8L44.1 47.5L44.7 46.9L47.5 48.6L48.4 48.4L48.7 50.2L49.8 51.9L51.3 51.9L53.1 52.7L55.3 56.3L61 56.2L61.6 60.8L60.8 62.1L61.8 63.1L61.7 65.1L62.6 65L64.2 66.7L67.3 66.6L68.9 67.8L69.1 69.3L70.3 68L71.5 68.6L74.7 66.8L74.6 65.1L75.7 64.6L74.8 63L77.8 62.7L80.9 61.4L81.5 61.8L81.6 60.7L83 60.5L84.7 59.2L85.5 58L85.3 56.1L86.6 53.8L86.3 52.1L87.1 51.6L88.9 50.9L91.7 51L93.3 48.2L97.2 50.3L98.9 50Z', w: 100, h: 69.3, b: [52.45, -47.45, 66.71, -37.56] },
TN: { d: 'M23.8 100L28.1 98.1L32.2 92.2L32.3 88.3L30.4 83L32.2 80.4L33.5 79.5L35.1 79.5L36.3 77.8L36.3 76.4L38.2 75.9L39.9 73.7L44.8 71.5L47.6 69.4L47.4 68.5L46.2 66.9L47.1 59.7L44.4 58.3L45.7 59.6L42.6 59.1L41.6 57.9L42.7 57.6L44 58.2L41.7 57.1L42.2 56.8L41.6 56.1L42 54.5L40.6 53L39.5 53.6L39.8 54.7L38 55.5L36.8 54.7L37.5 53.7L37.3 52.2L35.7 52.9L35.8 53.3L35.6 52.9L34 53.4L33.8 53.1L34.4 52.9L32.9 52.3L30.2 49.6L29 45.7L31.1 42.9L32.1 42.9L32.2 42.2L33 42.5L32.6 42.3L34 41L35.8 40.6L36.5 39.1L37.4 38.9L38 37.4L39.6 35.8L39.6 34.4L41 33.4L41.2 31.7L42.6 30.7L40.9 29.1L41.5 26.8L40.9 26.2L41.2 24.9L38.7 24.1L38.6 22.6L36.8 22.8L34.4 18.3L34.4 16.4L35.2 14.1L38.2 13.1L40.8 8.2L42 7.6L41.5 4.4L41 3.8L39.5 4.4L39.2 5.4L37.5 6.7L35.8 7L35.2 8.6L33.9 9.3L33.1 9.2L32.3 8.5L32.5 7.9L31.9 7.9L32.5 7.9L33 6.9L31 4.7L31.5 2.9L31.4 3.5L30.4 3.1L32.2 2.5L29.7 1.2L28.1 1.5L27.6 1L26.8 1.8L28.1 1.8L28 2.8L26.7 3.1L26.3 1.6L27.6 1L27.4 0.3L26.1 0L21.2 1.8L19.7 1.6L18.9 2.6L17.5 3.1L15.3 5.6L13.2 5.9L12.8 7.3L13.4 7.3L13.3 7.9L10.6 8.6L11.3 9.5L10.8 10.7L7.3 12.4L7.9 13.1L10.4 13.6L9.2 17L9.7 18.2L8.5 21.2L8.6 23.5L9.8 24.4L10.1 27L9.2 28.1L9.2 29.5L11.2 30.5L9.7 32.7L9.3 34.7L8.5 34.9L9.3 37.6L8.4 38.3L8.9 38.9L7 40.9L4 42.3L3.3 45.1L2.3 45.7L1.5 45.3L0.1 47.1L0 49.4L0.3 50.3L0 50.9L0.8 51.6L0.5 51.9L0.8 53L2.6 56.1L3.4 59.1L7.2 61L6.8 61.4L9.3 64.6L9.8 69L18.2 74.7L23.8 100Z', w: 47.6, h: 100, b: [7.52, -40.32, 11.6, -31.74] },
TO: { d: 'M5.6 93.4L5.8 93.1L5.1 93.6L5.3 94.7L5.9 95.2L6.3 95.2L6.4 95.5L7.2 95.5L8.4 96.7L9.3 96.6L9.3 95.9L9.2 95.9L9.3 95.9L9.6 95L10.4 94.8L10.1 94.3L9.3 94.1L8.8 94.5L9 95L8.2 95.5L8 95.2L8.7 95.2L8.5 95L8.2 95L8.2 94.8L7.5 95L7.4 94.8L7.4 95.2L7.1 95.2L6.9 94.8L7.4 94.7L7.4 94.5L7.9 94.7L7.9 94.8L8.2 94.7L8.2 94.8L8.2 94.7L8.7 94.7L8.5 94.3L7.9 94.5L7.1 94L5.6 94.1L5.5 94L5.6 93.4ZM27.3 50.6L27.1 50.8L26.8 50.8L26.8 50.6L26.8 51.3L25.9 51.4L26 52.3L26.3 52.3L26.7 51.9L26.5 51.8L26.7 51.6L27.1 51.8L27.3 51.6L27.1 51.3L27.5 51.4L27 51.9L26.8 51.8L26.8 51.9L27.1 51.9L27 52.4L27.1 52.3L27 51.9L26.7 52.3L27 52.6L26.8 53L26.8 52.8L27.1 52.8L27.1 53L27.3 52.8L27 52.6L27.3 52.4L27.3 51.9L27.5 51.9L27.5 52.3L27.6 52.3L27.5 51.9L27.8 51.9L27.6 52.1L27.8 52.1L27.6 51.4L27.8 51.8L28 51.8L28 51.3L28.3 51.8L28.6 51.6L28.1 51.1L28.1 50.6L28 50.8L27.3 50.6ZM12.4 97.8L12.2 97.4L12.4 96.9L12 96.9L11.9 97.6L11.2 98.4L11.2 99L12.4 100L12.5 99.8L12.4 97.8ZM9.6 70L9.3 70L9.2 71L9.6 71.3L10.1 71.3L10.4 71L10.4 70.7L10 70L9.6 70ZM1 0L0.3 0.2L0 0.5L0 0.8L0.6 1.3L0.6 1.2L1.3 1L1.3 0.2L1 0ZM16.5 54.1L16.2 54.1L16.1 54.5L16.4 55L16.7 55L16.9 54.5L16.5 54.1ZM22.3 70.3L22.3 70.1L22.2 70.5L21.5 70.7L21.5 70.8L22.3 70.8L22.5 70.7L22.5 70.3L22.3 70.3ZM10.6 69.6L10.9 69.5L10.8 68.9L10.4 68.9L10.4 69.6L10.6 69.6ZM21.5 71L21.5 70.8L21.4 71.7L20.9 72L21.7 72L21.5 71ZM22.3 69.1L22 68.8L22.2 68.9L22 69.1L22.6 69.8L22.6 69.5L22.3 69.1ZM25.2 52.3L24.7 52.6L24.7 53L25.4 52.3L25.4 52.4L25.7 52.3L25.2 52.3ZM14.6 79L14 79L14 79.4L14.5 79.5L14.3 79.5L14.6 79ZM26.5 52.6L26.2 52.4L26.2 52.6L26.5 52.6L26.3 53.1L26.7 53.3L26.5 52.6ZM20.6 73L20.4 72.9L20.2 73.6L20.7 73.2L20.6 73Z', w: 28.6, h: 100, b: [-175.68, 15.75, -173.9, 21.98] },
TR: { d: 'M92.8 7L88.6 3.6L87.8 4.7L82.6 4.1L80.4 6.2L75.3 8.4L71.3 7.1L65.5 8.5L62 6.8L61.2 7.7L57.2 5.3L55.2 6.1L53.4 2.9L49.3 2.8L48.2 1.4L48.9 0.6L47.8 0L46.2 1.1L38.9 0.5L28.5 5.6L27.6 7.1L16.6 6.3L15.8 7.9L17.1 9.2L20.8 9.8L14.7 10.9L16.6 11.8L16 12.3L10.2 12.4L10.5 11.4L9.2 11.1L9.3 12.7L7.4 12.6L6.6 11.6L3.5 12.1L0.7 14.8L0.1 18.4L4.8 17.8L3 19.9L4.5 21L4 22.1L5.5 22.6L3.6 24.1L4.9 25.6L6 25.6L3.6 25.6L3.4 26.4L2 23.9L2.5 25.8L1 26.7L2.9 27.9L3.8 27L4.4 28.3L6.5 28.7L6.5 30.2L5.1 30.8L6.3 31.3L6.1 32.9L7.3 32.4L7.6 33.6L8.6 33.6L7.6 34.7L6.5 34.3L6.5 35.5L12.2 34.9L10.5 35.7L10.6 36.6L7 37.4L11.1 36.6L10.2 37.3L10.6 38.2L12.9 36L15 38L15.4 36.8L16.3 37.4L16.3 39.3L19.4 41L22 39.8L23.3 40.6L24.7 36L31.9 38.4L33.7 40.8L35.9 41.7L40.8 41L41.6 39.8L42.2 40.4L45.8 36.6L49.6 38.4L53.1 35.8L54.2 37.6L51.9 40L52.6 42.4L53.9 43.1L55.1 40.4L56.8 40.2L55.9 38.7L56.6 36.4L60.9 37.7L65.1 35.8L70.4 37.4L78.4 34.4L82.5 34.7L86 33.1L86.8 34.6L88 34.3L89.1 32.7L94.7 34L96.4 33.1L97.3 35.5L100 33.4L98.7 32.3L98.8 30.1L96.8 29.3L98.3 26.2L97.2 25.9L97.3 23L95.8 19.1L97.9 18.9L98.9 16.4L100 17.2L97.5 14.6L93.8 14.1L93.2 11.5L94.3 9.7L92.8 7ZM1.7 2.8L3 3.6L3.1 5.4L1.5 6.1L1.7 8.3L0.1 10.4L4.2 10.8L0.9 12.6L0.6 14.6L6.7 10L7.8 8L11.6 7.3L13.3 8L13.4 7.3L14.9 8.2L16.3 6.3L11.6 4.1L10.6 0.9L9.7 0.6L8.1 1.4L6.7 0L5.4 0.1L3.1 0.9L1.7 2.8Z', w: 100, h: 43.1, b: [26.04, -46.5, 44.82, -38.4] },
TT: { d: 'M70.9 36.9L69.5 36.9L69.5 37.6L62.4 36.9L62.4 37.6L59.6 37.6L59.6 38.3L53.2 39L51.8 40.5L50.4 40.5L50.4 39.7L50.4 40.5L46.1 40.5L46.1 39.7L44 40.5L44 39.7L39.7 39L39 40.5L36.2 41.2L35.5 42.6L34 42.6L34.8 42.6L34 41.9L34.8 41.9L33.3 42.6L31.9 41.2L31.2 43.4L31.2 42.6L30.5 43.4L29.1 42.6L27 44.1L27 43.4L25.5 43.4L25.5 44.1L22.7 44.1L22.7 43.4L22 44.8L21.3 44.1L19.9 46.2L18.4 46.2L19.1 48.4L18.4 48.4L20.6 48.4L21.3 49.9L22 49.1L21.3 48.4L22.7 48.4L22.7 47.7L25.5 49.1L26.2 48.4L29.1 51.3L30.5 51.3L29.8 52.7L31.2 53.5L31.9 57.1L32.6 57.1L31.9 60.7L32.6 61.4L31.9 62.8L32.6 62.8L31.9 62.8L30.5 68.6L31.9 69.3L31.2 70.8L33.3 72.9L32.6 72.9L33.3 76.5L31.2 79.4L28.4 80.9L24.8 80.9L21.3 79.4L20.6 82.3L18.4 84.5L17 83.7L16.3 85.9L9.9 86.6L9.9 87.3L7.1 88.1L7.1 89.5L5 91L1.4 91L0 94.6L2.8 94.6L7.1 91.7L9.9 91.7L9.9 92.4L10.6 91.7L18.4 91.7L19.1 93.1L21.3 93.8L24.1 92.4L29.8 92.4L30.5 93.1L31.2 92.4L42.6 92.4L42.6 91.7L54.6 92.4L59.6 89.5L61.7 89.5L63.8 86.6L66 86.6L66 88.1L66.7 88.1L65.2 81.6L66 75.1L66.7 75.1L66.7 73.7L68.1 74.4L68.1 72.9L66.7 73.7L65.2 72.2L63.8 66.4L63.1 66.4L62.4 60.7L64.5 60L63.8 52.7L63.1 53.5L63.8 52.7L63.1 47.7L66 47L66.7 44.8L69.5 42.6L69.5 40.5L70.9 39L72.3 39L71.6 38.3L72.3 37.6L70.9 37.6L70.9 36.9ZM99.3 0.7L99.3 0L97.2 0.7L97.9 2.2L95 1.4L92.2 2.2L91.5 2.9L92.2 3.6L90.8 2.9L90.8 3.6L88.7 3.6L89.4 4.3L87.2 4.3L85.8 6.5L83.7 7.2L83 8.7L81.6 8.7L81.6 10.1L78.7 13L77.3 13L77.3 12.3L76.6 14.5L77.3 15.2L79.4 14.5L80.9 15.9L81.6 14.5L82.3 15.2L82.3 13.7L83.7 12.3L87.2 13L87.2 11.6L91.5 11.6L92.9 9.4L94.3 9.4L95 7.2L97.2 7.2L97.2 8L97.9 5.8L97.9 6.5L99.3 6.5L100 4.3L98.6 3.6L100 2.9L100 0.7L99.3 0.7Z', w: 100, h: 94.6, b: [-61.93, -11.42, -60.52, -10.09] },
TV: { d: 'M67 47.4L66.8 47.4L66.8 47.6L67 48.2L67.3 48.2L67.3 48.4L67.6 48.4L67.8 48.2L67.6 48.2L67.6 47.9L67.3 47.9L67 47.4ZM80.7 76.3L81 76.3L81.2 75.8L81 75.8L81 74.4L80.4 73.9L80.4 74.2L80.7 74.2L80.7 74.7L81 74.7L81 76L80.4 76.3L80.4 76.6L80.7 76.3ZM5.8 16.7L5.3 16.9L5.3 17.2L5.8 17.2L5.8 16.7ZM58.9 64.1L59.2 63.8L59.2 63L59.4 62.5L59.7 62.5L59.2 62.8L58.9 64.1ZM99.4 100L99.4 98.9L98.9 98.7L99.1 98.7L99.1 99.2L99.4 99.2L99.4 99.7L99.1 99.7L99.1 100L99.4 100ZM0.8 0.5L0.5 0.5L0.5 0L0.3 0L0.5 0.5L0 0.3L0.5 0.5L0.8 1.1L0.8 0.5ZM98.3 97.6L98.1 97.6L98.3 97.6L98.3 98.1L98.6 98.1L98.6 98.4L98.6 97.9L98.3 97.6ZM30.8 11.9L30.5 11.9L31 12.2L31 11.9L30.8 11.9ZM27.3 43.1L27.3 42.9L27.1 43.4L27.3 43.4L27.3 43.1ZM27.3 42.3L27.1 42.1L27.1 42.3L27.3 42.3Z', w: 99.4, h: 100, b: [176.13, 5.67, 179.91, 9.47] },
TW: { d: 'M40.4 0.3L38 1.5L36.9 3.3L38 3.9L38 5.4L37.7 4.2L36.1 3.9L35.6 4.5L36.6 4.2L34.8 5.4L32.3 5.4L26.7 8L24 13.1L24.8 13.1L23.4 13.4L24 13.4L23.4 13.4L23.4 16.4L21.3 18.4L22.1 19L20.5 18.7L19.1 20.2L19.9 20.5L18.3 20.5L17 24.4L14 28.2L14.6 28.8L12.7 30L13.5 30.3L11.9 32L12.4 30L11.3 32L12.9 32.9L11.9 32.6L11.3 33.8L9.7 34.7L9.2 35.9L10.8 35L10.5 36.2L8.9 36.2L8.9 36.8L10.2 36.2L8.6 37.4L8.4 38.8L7 40.3L7.5 40.3L6.7 42.4L5.9 42.4L6.7 43.6L8.9 43.6L7.8 44.1L4.9 43.6L3 45L4 44.4L3.2 45.6L4.3 45.6L3.5 46.8L3.2 46.2L3 46.8L3.8 47.4L2.4 49.7L3.2 52.7L2.2 52.7L2.2 53.6L3.2 53.6L2.4 54.7L3.5 54.7L3 54.7L3.2 56.8L2.4 57.1L3.2 56.8L3.2 57.4L2.2 57.7L2.7 58.5L2.2 58.3L0.5 62.7L1.3 64.4L0.3 64.7L0 65.6L0.3 66.5L1.1 66.2L1.1 67L2.7 68.2L3.2 67.9L3 69.1L3.8 68.5L3 69.1L4 72L4.6 71.4L4 72L4.6 73.5L4 73.5L4.9 73.8L5.9 77L6.2 76.4L5.9 79L6.7 79L8.6 81.7L7.3 81.1L7.8 82L9.4 83.1L10.2 83.1L10.2 82.5L11 84L14.6 86L16.2 88.1L18.3 93.9L17.5 95.9L18.3 99.4L19.7 98.3L22.4 100L21.6 97.4L23.2 96.2L22.9 87.5L25.3 80.8L26.4 78.2L30.7 74.9L30.2 74.7L31.3 74.1L31 72.3L32.6 71.4L35 67.3L35.3 65.6L36.9 64.4L36.6 61.8L37.5 59.4L38.5 58.5L42.3 39.4L43.4 38.2L42.6 37.4L42.6 35.6L43.7 35L43.4 34.1L43.9 34.4L43.7 32.6L46.6 29.7L47.4 25.5L49 24.4L48.5 23.2L49.6 22.9L49 20.8L49.9 20.8L48.5 19.6L48 17.5L48 13.7L50.1 10.4L53.1 8.6L52.3 8L50.9 8.3L51.5 8.3L50.4 6.6L50.7 5.1L48 5.4L48.2 4.8L47.7 5.1L46.6 4.2L45.8 5.1L46.1 4.2L44.5 3.9L44.7 2.4L43.9 3L42.6 0.6L40.4 0.3Z', w: 53.1, h: 100, b: [120.04, -26.16, 122.01, -22.45] },
TZ: { d: 'M12.9 65.3L15.2 68.9L18.4 68.8L20.1 69.9L20.2 71.1L21.1 71.9L22.3 71.6L23.5 72L23.9 73.3L27.9 73.7L28.9 75L30.7 75.1L32.3 76.2L33.1 76L34.4 77.2L36 77.1L37 78.2L39.8 77.9L41.4 79.2L41.5 77.6L42.3 77.1L45 79.6L46.8 82.3L47.1 86.8L48.1 88.5L48.1 90.2L47.5 91.3L49.1 93.8L50.4 94.5L50.6 96.2L56.8 96L58.6 94.6L59.5 94.7L60.5 95.8L61.7 95.9L61.9 97.4L64.5 97.1L64.6 97.9L65.1 97.3L65.7 97.7L67 96.8L67.3 95.9L72.8 97.2L73.5 96.5L74.2 96.7L75.8 95.9L76.5 93.7L77.3 93.3L80 93.4L82.4 94.7L85.9 92.4L89.3 92.5L91.9 90.6L96 89.1L99.9 86L99.2 85.5L100 85.1L99.8 84.6L99.5 85.1L98.9 84.9L98.2 83.6L97.7 84.1L98.4 84.2L98.2 84.7L97.7 84L97 84.3L97.3 83.5L95.9 82.9L95.8 83.6L95.8 82.8L94.4 81.5L93.7 81.5L93.2 82.6L92.9 82.5L93.4 81.3L94.1 81.1L94.2 80.2L93.4 79.3L93.8 79L93.2 77.2L91.9 76.1L92.9 76.1L92.6 73.2L92 72.9L92.4 74L92 73.4L91.4 74.2L91.4 72L91.1 71.7L90.5 72L90.5 71.3L92.1 71.9L91 70.2L90.2 69.9L90.3 68.9L89.6 68.2L89.8 66.8L89.4 66.1L90.2 66L90 65.2L91.1 63.6L90.5 62.8L91.1 61.8L90 61.7L90.1 61L89.7 61.4L89.5 59.8L90.2 57.3L91 56L92.1 55.6L92 54.3L91.4 53.2L89.9 52.8L89.5 53.2L89.6 52L89.3 52.2L88.8 51L87.4 49.6L85.7 48.9L85.8 46.8L85 45.9L85.4 43.9L87 40.6L86.8 40.2L88.3 37.4L87.8 36.7L88.4 36.4L88 35.5L88.6 35L88.6 35.7L89 34.9L89 34.4L88.5 34.4L89 34L88.8 33.2L76.2 24.4L75.7 23.1L74.4 22.9L74.3 22.2L75.4 20.6L75 18.8L74.3 18.2L42.3 0.2L12.9 0L11.8 0.8L10 0.9L11.1 3.2L12.6 4.2L13.5 6L13.2 8.7L14.1 10L13.3 12.5L10.8 12.9L9.7 15.1L10.7 15.3L9.9 15.9L9.6 17L10.3 17.8L11.2 17.2L11.9 18.1L13.6 18L13.2 18.7L13.5 20.6L11.9 21.1L12 22.1L10 23.3L9.6 25.3L8.8 25.3L6.3 29.8L4.9 30.7L4.2 30.5L3.8 31.5L0.6 31.2L0 35.4L2.6 42.5L1.4 45.5L1.9 48.1L3.4 50.9L6.8 53.1L8.9 55.8L10.1 59.8L11.8 62.3L12.9 65.3Z', w: 100, h: 97.9, b: [29.34, 0.98, 40.44, 11.84] },
UA: { d: 'M8.2 7.8L11.1 13.5L10.1 14L10.7 16.5L2.8 25L4.1 29.4L2.3 28.9L0 34.6L4.1 38.3L5.7 36.9L7.3 38.1L13.5 38.1L15.2 40.2L22.4 38L25.1 34.9L25.9 35.4L25.3 34.5L30 33.8L34.3 35.8L35 37.3L35.6 36.4L38.9 38L38.8 42.4L40.3 43.7L41.2 43.1L41.1 46.5L43.3 47.4L42.8 49.8L44.4 50.8L41.3 51.3L39.8 50.1L39.1 51.1L39.3 49.6L37.6 50.4L37.8 54.1L35.2 56.2L35.3 58.1L33.6 58.3L35.5 60L39.8 58.5L42.1 60.2L41.4 55.4L42 57.3L44.2 54.8L41.9 57.4L46.1 53.5L44.3 51L46.2 53.5L47.6 49.7L51.3 49.1L52.2 47.4L51.4 49.1L53.9 49.1L54.5 47L54.4 48.9L57 50.2L51.8 49.5L53 50.9L54.8 50.7L53.3 51.9L57.5 53.6L61.3 52.4L62.9 53.9L63.4 52.9L63.9 55.4L61 55.7L57.2 59.3L61.2 58.5L60 59.5L63.4 62.1L62.3 65.5L66.3 66.5L73.9 61L79 61.4L80.3 58.9L75.7 58.3L73.8 59.7L70.1 52.9L70.1 55.6L73.7 59.8L71 59.2L71.5 57.2L69.2 56.6L69.8 55.5L68.8 56.3L70 54.9L68.4 55.8L69.1 54.4L67.9 55.1L66.2 53.5L66.4 54.6L64.3 52.2L66.4 53.2L66.4 52.3L66.6 53.5L66.4 51.8L67.3 53.3L68.6 52.5L68.3 54.5L69.1 52.7L71.6 51.9L72.2 49.9L73.1 51.2L71.1 53.5L76.2 48.8L78.3 49.1L77.4 50.2L79 48.2L80.7 47.8L80.9 49.1L82.2 47.1L83.9 47.2L85.3 45.2L88.3 45.8L89.7 41L91.9 40.5L92.6 38.9L97.3 39.3L98.8 36.1L96.9 32.8L97.6 30.9L99.2 30.6L96.9 29.5L100 27.4L99.6 24.3L97.7 24.8L89.5 20.2L87.9 21.9L84.9 17L80.4 19.1L77.2 17.1L74.6 18L73.3 16L73.4 11.9L72.2 11.9L72 10.4L66.9 10L67.3 7.7L66 6.4L67.9 5L64.5 0.1L59.6 1.2L56.6 0.4L55.1 3.2L48.7 2.6L46.3 6.3L46.5 10L44.5 7.8L39.7 9L38.6 6.5L36.6 8.7L33.9 6.3L31.5 6.7L31 8.2L30.8 6.9L28.2 7L28 5.5L17.3 3.7L12.3 4.5L10.3 7.2L9 6.4L8.2 7.8Z', w: 100, h: 66.8, b: [22.14, -61.71, 40.22, -49.63] },
UG: { d: 'M0.4 98.4L1.6 98.4L2.6 97.5L4 98.1L4.6 97L5.4 97.9L5.8 99.8L6.3 100L7.9 98.8L8.4 99.1L8.9 98.1L9.6 98.4L10.5 97.7L10.5 96.3L11.2 96.5L12.6 95.1L13.7 92.6L18.9 92.8L21 91.2L21.4 91.6L76.4 91.6L76.4 82.1L77.3 76.3L76.1 72.1L79.6 67.7L79.2 66L79.8 65.6L80.1 63.9L82.6 62.8L83.1 60.7L84 60.6L85.6 59L86.8 54.8L87.7 54.8L87.8 53.9L89.6 52.8L91.7 52.7L92.2 52L91.2 49.2L92.6 48.8L92.7 47.2L94.5 46L95.7 41.3L93.6 32.1L94.5 30.7L93.6 30L93.4 28.6L92.6 28.8L91.7 27.6L91.2 24.6L90.1 23.7L89.1 23.9L88.2 22.8L87.3 19.1L85.7 18.4L85.6 16.5L84.7 14.9L85 13.5L84.5 13L85.6 12.5L85.7 9.7L84.7 9.3L84.3 8.6L83.3 8.6L82.9 9.5L83.1 9L82 7.7L80.5 7.9L81 7.6L80.3 7.4L81.7 6.5L81.5 6L79.2 6.3L79.9 4.6L78.7 3.7L79.2 2.8L78.5 0.7L77.5 0L69.1 8.3L63.5 7.7L60.7 5.8L58.4 7.2L56.3 7.7L50 8.4L46.1 10.9L46.5 11.2L46.1 12.7L44 12.1L43.7 11.1L41.7 11.1L41.4 9L39.3 6.7L37.5 9L34.7 10L30.2 7.6L27.9 7.6L27 8.4L26.1 8.4L25.8 9.1L24.5 9.3L22.8 12.8L22.4 12.7L23 13L24 12.5L24 14.4L22.8 16.7L22.3 16.7L21.6 20.4L21 20.7L22.6 22.1L23.1 24.8L21.9 25.8L21.4 28.1L20.7 28.6L20.5 31.3L22.3 31.6L23.3 33.2L24 33.2L24.2 32L25.2 32L27.2 34.4L28.6 33.9L28.6 35.1L30 35.8L30.5 36.9L25.6 43.4L20 47.7L16 52.8L13.8 53L13.5 53.9L12.4 53.5L11.7 54.1L11.6 56.9L10.5 57.4L10.7 58.1L10.2 58.6L9.5 58.3L7.4 59.1L7 59.8L6.7 62.8L7.2 65.1L5.3 67.2L4.4 71.2L3.7 71.1L2.6 72.6L3.2 74.6L1.4 82.1L1.9 82.6L1.9 84.2L1.1 86.1L1.2 89.8L0.2 89.8L0.5 91.4L0 94.9L0.7 95.4L0.4 98.4Z', w: 95.7, h: 100, b: [29.57, -4.22, 35.03, 1.48] },
US: { d: 'M86.5 77L87.1 74.9L85.8 69.2L90.7 65.6L90.1 64.9L91.3 64.7L90.3 64.3L91.1 63.4L91.5 64.5L90.1 62.7L90.8 63L90.1 61.8L90.8 61.9L89.7 61.3L90.7 61.7L90.4 60.2L91.2 59.8L91.1 62.9L92 61.3L91.5 59.6L92.2 60.6L93.4 57.9L97 57.1L95.9 56.2L96.7 54.3L100 52.8L98.8 49.2L97.7 49L96.2 52.2L92.1 52.5L90.3 54.4L88.4 54.4L88.1 55.5L84.5 57L84.6 52L83 50.4L78.8 47.8L75.7 48.2L72.7 47.1L72.3 46.2L44.8 46.7L45 49.3L44.8 48L42.9 47.6L44.2 50.8L43.2 58.9L46.9 66L49 66.6L50.4 68.4L52.8 68.2L56.4 69.8L61 69.3L64 72.4L66 71.6L68.2 75.3L69.9 76L69.8 73.7L72.3 71.7L75.4 71.5L77.8 72.5L78 71.4L77 70.9L83.2 71.2L86.5 77ZM26.8 5.2L24.6 3.8L22.6 4.2L18.4 2.6L16.7 3.1L15.3 2.6L15.7 1.7L13.7 1.9L12.9 0.8L12 1.9L12.5 0.9L11.5 0L10.2 1.6L8.4 1.8L8.3 3.6L7.9 2.4L6.2 3.3L4.2 7L1.9 7.3L1.3 8.8L4.3 12L7.8 13.5L6.4 13.6L5.6 12.4L7 14.5L4.3 14.6L3.6 13.3L0 15.6L2 16.5L1.2 16.6L1.7 17.9L3.1 18.4L4.9 17.9L5.3 18.7L7.2 17.5L6.9 20.5L4.8 21.6L3.7 21.2L2 24.2L2.5 25.7L4.7 26.4L3.6 26.8L3.1 26L2.7 26.8L3.7 26.8L4.1 28.2L5.8 27.5L6.3 30.7L7.7 29.6L9.2 31L9.5 29.5L9.8 30.5L11.2 29.7L10.6 32.7L7.7 34.8L7.8 35.7L6.3 35.5L4.7 37.4L9.6 35.3L14.7 30.1L13.7 29.5L15 27.3L18.5 24.8L17.8 25.5L18.9 26.2L16.5 26.4L16.1 28.3L17 28.3L16.2 29.5L18.5 27.6L19.5 28L19.9 26.9L19.2 26.3L20.2 25.3L20 26.2L21.6 25.6L21.2 26.5L23.6 27.8L26.4 27.6L27.5 28.5L28.3 27.7L28.9 28.2L27.9 28.8L31.1 31.3L31.7 30.9L30.7 29.7L32.6 31.3L32.4 28.9L33 31L34 30.7L33.7 31.6L34.6 32L34.2 33.2L36.2 34.9L35.6 36.1L36.7 35.1L36.8 37.4L37.4 37.2L37.6 35.1L35.9 34.3L32.5 28.4L30.2 30L28.7 27.2L26.8 27.2L26.8 5.2Z', w: 100, h: 77, b: [-168.13, -103.64, -66.95, -25.77] },
UY: { d: 'M4 60.7L4 61.3L2.1 61.1L1.4 61.7L1.6 65.4L0.3 66.1L0.3 67.3L2.8 66.5L1 66.9L0.2 68.9L0.3 77.8L3.3 80.6L4 83L6 83.3L6.5 84.5L8.1 85.1L10 89.5L14.1 88.5L15.3 89.1L16.4 88.5L22.2 88.9L27.7 93.9L30.3 94.3L32.2 95.6L34.3 95.2L36 95.8L35.8 94.5L34.6 93.9L35.7 94.1L36.2 95.8L34.6 96.8L36.5 98.5L38.4 97.9L38.3 98.5L39.3 99.2L47 95.2L50.1 96.2L52.4 95.8L53.8 96.6L54.6 98.5L58.2 97.9L58.4 98.7L60 98.9L60.1 100L61.5 98.7L61 98.1L61.5 98.7L62.5 98.5L70.8 94.1L73.9 93.5L73.9 92.4L75.5 90.6L80.3 88.1L80.3 85.1L84.3 81L84.9 77.4L87.4 74.3L86.5 74.3L86.2 73.1L84.6 73.1L84.8 61.9L86 59.9L89.6 57.4L88.6 56.8L92.4 54.1L92.4 53.3L89.4 50.6L88.6 51.1L88.1 50.2L86.3 50L85.6 48.2L84.1 48L82.5 46.2L82.7 44.5L81 41.5L81.2 40.2L79.4 39.6L78.9 38.2L77 37.4L77.2 36.8L75.6 36.4L75 37.2L68.8 31.5L68.8 30.3L66.5 27.5L63.6 26.7L62 27.1L61.2 25.7L60.3 25.9L59.3 23.6L57.9 24.9L55.1 23.4L54.3 21L53.4 20.8L53.2 19L52.2 18.8L49.3 14.8L47.9 15.4L47.7 17.4L46.9 17L46.4 18.2L44.1 19.8L41.9 19.8L42.2 15.2L41 13.2L39.8 13L39.1 10.4L38.3 10.4L38.1 9.6L37.6 10L37 8.6L35.3 8.2L34.1 5.8L33.6 6.2L32.6 5.4L32.7 4.4L31.4 4.2L30.8 2.2L30 2.4L28.1 0.2L23.4 0L21.4 4L19.8 4L19.5 3.4L18.1 4.2L17.4 3.6L15.7 4L15 2.2L13.6 2L13.8 5L9.5 8.2L9.5 9.8L11 12.4L10.9 16.6L9.1 17L10 18.6L9 21.2L9.3 22.4L7.9 24.4L7.9 26.1L6.2 27.3L6.2 28.1L7.9 29.7L7.4 32.1L6.2 34.8L4 35.8L5.2 38.4L4.5 41.9L5.9 43.5L5.7 45.1L4.1 47L5.7 58.2L5.2 59.9L4 60.7Z', w: 92.4, h: 100, b: [-58.44, 31.58, -53.08, 37.38] },
UZ: { d: 'M68.7 65.6L69.2 62.8L70.7 60L71.6 60.1L72.3 58L70.4 56.6L70.4 53.4L71.1 52.5L70.4 51.8L68.2 52L68.1 50.9L66.1 50.3L67 49.2L66.8 47.7L68.8 47L73.2 48L73.6 45.7L75.2 45.5L74.9 43.9L75.7 44.1L76 43.2L73.9 43.8L73.1 43.3L77.6 43L77 40.3L77.8 40L78 38.4L80.1 39.6L83.7 37.6L84.1 36.5L86.3 38.9L83.6 41L84.9 41.7L85.3 43.1L89.1 41.8L90.8 42.9L91.4 42.4L91.8 43.3L92.4 42.5L93.5 42.5L93.1 42L93.8 41L94.7 40.7L95.7 41.5L95.3 39.9L97.1 40.6L97.7 39.3L99.9 38.5L99.8 38L96.8 37.9L95.3 36.3L94.3 36.9L94.4 35.6L92.7 35.6L92.7 34L91.4 33.5L91.7 32.5L91 33.1L91.2 34.2L90 34.6L89.8 35.9L88.6 35.3L87.2 35.6L86.6 34.9L86.2 35.3L85.8 33.2L84.4 33.7L82.6 32.8L88.9 27.2L87.6 26.7L86.2 27.4L85.2 28.9L84.4 28.1L81.4 31.1L78.4 32.4L78.5 33.2L75.9 34L76 35.1L74.2 36.4L74.3 37L72.9 36.8L73.8 39.9L72.8 40.1L69.6 38.2L70.7 36.6L69.9 36.6L69.8 35.6L62.4 35.7L61.5 30L58.3 29.5L58.9 21.6L57.4 22.2L55.7 18.8L52.5 15.3L49.7 16.5L42.6 15.9L35 17L29.2 9.6L23.3 4.9L15.1 0L0 4.9L0 34.3L6.1 34.9L6.8 33.9L6.1 33.5L5.8 29.7L7.5 27.9L10.8 27.7L11.1 25.6L12 24.8L14.6 26.8L12.5 24L15.2 23.9L15.3 22.7L17.6 24.8L18.5 24.8L20 26.7L22.7 26.5L23.3 27L23.6 28.9L22.9 29.1L25.2 30.8L23.6 31L24.4 32L23.7 33.2L24.4 34L26.2 35.2L29.4 34.7L30.4 35.6L31.8 34.5L34.3 35.9L38.3 45.2L55.6 57.3L57.8 57.4L62.2 59.5L62.4 60.1L61.5 61.2L61.5 64.3L63.9 63.9L65.7 65.5L67.2 64.8L68.7 65.6Z', w: 100, h: 65.6, b: [56, -51.34, 73.15, -40.09] },
VA: { d: 'M0 0L0 100L74.4 100L74.4 0L0 0Z', w: 74.4, h: 100, b: [12.45, -46.24, 12.46, -46.23] },
VC: { d: 'M35.3 0L32.9 0L31.7 1.3L30.5 1.3L30.5 2.5L29.2 3.8L29.2 5L28 6.3L28 7.5L26.8 8.8L26.8 11.3L24.4 11.3L23.1 12.5L23.1 15L21.9 16.3L21.9 23.8L23.1 23.8L23.1 25L24.4 25L24.4 26.3L25.6 26.3L25.6 28.8L28 28.8L28 31.3L28 30L30.5 30L30.5 31.3L31.7 31.3L31.7 32.5L35.3 32.5L35.3 31.3L37.8 28.8L37.8 25L39 25L39 22.5L40.2 21.3L40.2 18.8L41.4 17.5L41.4 6.3L40.2 6.3L40.2 3.8L39 3.8L39 1.3L37.8 1.3L39 1.3L37.8 0L37.8 1.3L36.5 1.3L36.5 0L35.3 0ZM30.5 42.5L30.5 41.3L30.5 42.5L29.2 42.5L29.2 43.8L26.8 43.8L26.8 45L25.6 45L25.6 46.3L26.8 46.3L26.8 47.5L25.6 47.5L25.6 48.8L20.7 48.8L24.4 48.8L24.4 50L26.8 50L26.8 48.8L26.8 50L28 50L28 48.8L29.2 47.5L29.2 45L30.5 45L30.5 42.5ZM2.4 95L2.4 96.3L1.2 96.3L1.2 97.5L0 97.5L0 98.8L1.2 98.8L1.2 100L1.2 98.8L1.2 100L2.4 100L2.4 98.8L4.9 98.8L4.9 97.5L6.1 97.5L4.9 96.3L4.9 97.5L3.7 96.3L4.9 96.3L3.7 96.3L3.7 97.5L3.7 96.3L2.4 96.3L2.4 95ZM15.8 80L15.8 85L14.6 85L13.4 83.8L13.4 85L13.4 83.8L13.4 85L14.6 86.3L14.6 85L15.8 85L15.8 86.3L15.8 85L17.1 85L17.1 82.5L18.3 82.5L18.3 80L17.1 80L17.1 81.3L15.8 81.3L15.8 80Z', w: 41.4, h: 100, b: [-61.46, -13.5, -61.12, -12.68] },
VE: { d: 'M48.1 81.6L52.1 85.2L57.5 83.3L57.7 85.9L59.3 83.7L60.5 83.7L60.9 82.1L61.7 82.5L63.2 80.9L63.7 81.6L66.2 79.4L66.6 80.6L68.6 78.5L69.1 76.1L73.6 74.8L73.7 72.7L68.7 72.2L69.2 70.5L67.4 67.6L68.2 65.1L63.5 60.8L63.1 59.1L64.5 60.3L68.1 60.3L69.3 62.1L71.6 61.7L71.4 61L72.8 62.3L73.3 61.3L74.9 61.7L76.8 64L77.7 63.1L78.4 63.6L78.4 60.8L79.8 61L80.4 59.8L82.9 60.5L87.1 59.3L88.8 57.2L91.3 57.3L93.5 55.1L94.3 54L93.7 52.3L88.2 46.6L90.3 44.9L89.5 42.1L90.1 40.8L93.4 40.7L96.5 38.4L93.9 37.1L93.3 34.9L94.6 32.8L98.4 31.2L100 29.2L96.8 26.6L95.1 27.7L92 27L92.6 24.1L91.5 23.9L92.3 22.6L91.2 22.7L92.8 21.4L91.1 19.8L86.6 17.3L87.3 18.6L85.1 18.4L82.7 16.4L81.3 17.9L80.2 15.3L79.4 14.7L79.3 15.8L78.5 13.6L77.3 13.8L77.9 12.3L81.3 12.6L84.8 10.9L67.2 11.5L67.4 12.8L68.9 12L71.8 12.9L67.7 12.9L63.5 15.8L61.4 16.1L55.3 14.2L56.5 15L55.7 14.9L52.7 11.7L40.1 13.2L37.8 12.2L37.2 10.2L38 9.9L34.7 6.4L30.3 5L27.5 5.4L24.9 0L23.2 0.8L23.1 4.3L26.3 3.8L26.8 5.5L23.8 4.7L24.7 5.5L23.4 6.4L13.9 9.3L14.3 10.5L13.1 10.5L13.9 13.6L17.3 18.7L16.9 21.8L14.8 23.4L12.2 23.6L11.4 22.4L12.2 21.1L10.1 20.7L10.2 19.5L10.6 20.2L9.1 18L12.8 13.2L13.2 11.3L11.8 9L13 9L11.1 7.6L10.3 4.8L15 2.7L10.3 4.1L8.3 8L6.4 8.4L3.5 13.2L3.2 17.7L0 22.7L2.7 21.8L3.2 23.4L4.5 23.3L5.2 26.8L7.5 30.2L7.5 31.5L6.5 31.9L6.6 35.3L8.8 36.2L9.9 38.8L17.3 39.1L19.8 38.2L22.5 39.4L24 39.1L30 45.9L31.3 44.7L37.6 45.1L40.9 43.9L43.6 44.8L44 46.3L42.6 47.6L42.4 50.2L40.6 51.7L40.6 57.3L42.3 62.8L43.3 62.8L44.7 65.7L40.6 70.1L42.5 69.9L45.4 73L48.1 81.6Z', w: 100, h: 85.9, b: [-73.37, -12.29, -59.81, -0.65] },
VN: { d: 'M15 88.6L16 90.4L17.2 89.9L19.4 91.6L17.8 93L17.3 99.4L20.5 99.6L22 97.2L26.5 95.4L24 91.4L28.4 94.3L28.9 93.1L26.3 90.2L29.2 92.6L29.7 92.1L27.5 90L29.2 91.5L30.2 90.8L28 89.4L30.2 89.6L30 87.6L30.9 88.9L31.7 87.8L32.6 88.5L32.2 89.2L33.3 88.9L44.8 82.4L44.8 80.5L45.6 81L46.3 79.9L45.6 78.7L46.6 78.9L45.6 75.1L47 75.4L46 73.8L46.9 72.7L46.9 73.7L47.8 73.7L46.1 68.8L46.9 68.3L46.2 67.6L46.7 65.4L43.9 55.6L42.1 54.7L39.9 51.1L40.3 50.2L39.6 51.1L38.4 48.9L37 49.2L34.5 46.7L35.7 47.3L32.5 45.4L32.5 43.8L28.2 39.6L28 36.7L24.4 34.8L22.7 31L24.7 24.5L28.9 21.8L28.5 20L29.8 19.1L28.7 18.5L30.4 19.1L29.1 16.5L30.9 17.6L31.9 16.6L33 17.3L34.1 16.7L34.3 14.5L35.5 14.9L36.6 13.3L37.8 13.8L38.5 13L37 12.2L34 12.6L31.7 11L31.9 10.2L29.7 10.1L28.8 6.5L30.6 4.1L28.6 3.1L26.9 3.7L26.1 2.8L24.3 3.3L20.8 0L17.3 1.9L17.8 3.1L16.9 4L14.6 5L13.9 3.9L12.8 4.1L12.1 6.2L9.8 4.2L9.1 5.7L7.7 4.1L5.8 6.7L2.2 4.3L0 7L3.1 10.4L3.5 12.3L4.5 10.9L4.7 11.9L5.5 11.6L4.4 15.1L6.3 17.5L10.2 19.2L10.9 17.7L12.8 17L16.3 19.1L14.5 20.6L15.3 21.2L16.5 20.5L16.1 22.1L18.6 23.1L17.6 25.2L16.4 26.4L12.6 26L12.9 27.3L11.3 28.6L19.9 33.1L19.8 35.4L21.8 36.2L23.5 39.8L28.7 44.3L29.5 48.1L30.7 47.5L31.5 49.2L34.7 50.7L33 53L35.8 55.8L35.7 57.6L34.7 57.8L35.3 60.5L33.8 64L35.7 67.8L34.8 71.7L35.3 75.7L32.9 76.1L29.7 78.3L28 78.1L28.1 80.3L25.4 79.5L23.9 80.6L24.3 82.9L26.4 84.3L26.4 86.1L24.2 85.6L23.7 84.5L20.9 85.6L19.2 85L19.3 86.6L17.8 87.9L15 88.6Z', w: 47.8, h: 100, b: [102.14, -24.07, 109.47, -8.72] },
VU: { d: 'M1.3 14L0.4 14.6L0 17.2L1.3 19.6L1.9 22.9L1.5 25.5L3.5 28.2L3.5 29.5L4.5 29.8L4.8 28.9L5.6 29L5.1 28.6L8.3 28.7L9.2 27.3L10.7 27.5L9.5 25.4L9.8 24.2L8.9 23.5L9.4 23.4L9.1 21.6L8.3 21.7L7.9 20.8L7.6 19L8.2 18.7L6.6 18.7L6.4 21.4L5.3 22.3L4.2 22.2L3.2 16.7L2.2 16L1.3 14ZM10.5 32.8L9.6 33.3L9.2 36L10.4 37.1L11.7 36.3L12.6 37.4L12.6 40.3L13 40.3L12.9 41L13.6 42.1L13.2 42.1L13.2 42.7L14.3 43.6L14.9 42.8L15.5 43L15.8 42.1L18.3 42.8L18.6 41.9L19.3 41.6L18.9 40.9L18.3 41.6L18.4 39.8L17.1 39.3L17.1 38.6L16.2 38.6L14.9 37.1L14.2 37.1L14 36.1L13.7 36.8L12.9 36L12.9 34.8L11.8 33.9L12 33.4L10.5 32.8ZM37.7 74.9L36.7 74.9L36 75.7L36.2 78L35.8 78.8L40.3 80.6L41.1 79.8L40.9 79.2L38.9 77.5L40 77.1L39.8 76.6L38.6 76.6L38.6 75.7L37.7 74.9ZM26.3 58L24.3 59.8L24 61.2L25.4 60.4L26.2 61.5L25.3 62L26.3 61.2L26 62L27 62L27 62.6L29.7 62.1L30.3 60.4L29.5 60.4L27.9 58L26.3 58ZM23.8 36L22.4 37.5L20.2 38L20.3 38.7L23.4 40.3L25.7 40L26.2 39L24.7 38.1L24.4 36.5L23.8 36ZM40.8 85.4L39.9 85.9L39.5 87.6L40.5 89.6L42.7 90.8L43.7 88.7L41.5 87.6L41.7 85.7L40.8 85.4ZM24.3 26.4L23.2 29.8L24.1 31.7L24.6 34.8L25.4 34.5L25.7 33.1L24.3 26.4ZM24.3 43.3L23.7 43.3L23.1 45L24 47L25.3 47.1L26.6 46.5L28.5 47.4L28.6 46.7L28.1 46.2L26.7 46.4L26 45L25.1 45.1L24.3 43.3ZM21.3 23.5L18.7 24.2L16.7 26.3L19.4 26.6L21.5 24.2L21.3 23.5ZM14.8 6.8L13.2 7.4L12.9 9.3L14.9 9.6L15.5 9.2L15.9 7.5L14.8 6.8ZM14 0L12.7 0.8L12.6 1.8L13.6 3L13.6 3.8L14.2 3.6L14 2.9L15.6 2.4L14.9 2.4L15.2 1.7L14 0ZM23.4 18.3L22.9 18.7L23.8 22.3L23.8 25.5L24.6 25.7L24.7 22.5L23.4 18.3ZM9.2 29.2L8.3 29.2L8 30.1L10.2 31L10.5 29.3L9.2 29.2ZM47.5 98.1L46.6 98.3L46.3 99.1L46.9 99.8L48.2 100L48.7 98.9L47.5 98.1Z', w: 48.7, h: 100, b: [166.52, 13.84, 169.85, 20.69] },
WS: { d: 'M35.3 0.7L29.5 0L27.3 2.2L25.2 1.5L25.2 2.2L22.3 2.2L20.9 3.7L19.4 3.7L19.4 3L15.8 4.4L15.8 5.2L12.9 4.4L12.2 5.2L12.9 5.9L10.8 5.9L10.8 4.4L6.5 5.9L3.6 3.7L2.9 4.4L1.4 3.7L0 5.2L0 6.7L12.2 17L11.5 17L13.7 20L15.8 20.7L16.5 24.4L18.7 25.2L19.4 26.6L21.6 25.9L22.3 26.6L22.3 25.9L24.5 25.2L25.2 25.9L28.8 24.4L28.8 25.2L29.5 24.4L29.5 25.2L30.2 24.4L34.5 25.2L35.3 23.7L37.4 23.7L38.8 25.2L38.8 26.6L42.4 26.6L43.2 25.9L42.4 21.5L44.6 20.7L43.9 20.7L43.9 19.2L46 17.8L46 16.3L44.6 14.8L43.9 11.1L43.2 11.1L43.9 11.1L43.2 10.4L43.9 9.6L42.4 9.6L41.7 7.4L35.3 0.7ZM70.5 25.9L63.3 26.6L63.3 27.4L61.2 27.4L60.4 28.9L56.8 28.1L52.5 31.8L53.2 31.8L53.2 34.1L57.6 34.8L57.6 36.3L59 37L58.3 37.8L59.7 37.8L59.7 36.3L60.4 36.3L61.2 39.2L63.3 40L64 41.5L66.9 41.5L68.3 40L71.2 41.5L69.8 40.7L70.5 42.2L74.1 42.2L74.8 44.4L80.6 43.7L80.6 44.4L87.8 44.4L87.8 45.2L89.2 44.4L89.9 45.2L90.6 44.4L90.6 45.2L92.1 44.4L99.3 45.2L98.6 45.2L100 43L99.3 40.7L97.8 39.2L97.8 40L96.4 40L95.7 38.5L96.4 38.5L95 38.5L95 37.8L92.8 37.8L93.5 37.8L92.8 36.3L89.2 37L91.4 35.5L89.2 33.3L87.8 33.3L88.5 33.3L87.8 31.8L84.9 32.6L82.7 31.1L79.1 31.1L76.3 28.1L74.8 28.9L74.1 27.4L74.1 28.9L73.4 28.1L73.4 28.9L70.5 25.9Z', w: 100, h: 45.2, b: [-172.83, 13.56, -171.44, 14.19] },
XK: { d: 'M30.3 98.6L31.3 100L34 98.6L35 100L37.6 100L40.2 95.8L38.7 93.7L39.7 93.7L38.7 88.1L40.2 84.5L46.5 83.1L48.6 80.3L53.3 79.6L56.9 75.4L60.1 76.1L62.2 80.3L62.7 79.6L63.2 83.8L65.8 83.8L68.4 82.4L67.4 80.3L69.5 76.8L68.9 75.4L70 76.1L71.6 72.6L73.6 72.6L74.7 74L74.7 70.4L78.3 71.2L78.9 73.3L82 72.6L81.5 68.3L78.9 66.9L79.4 64.1L81.5 64.8L81.5 63.4L84.1 63.4L85.7 61.3L84.1 57.7L86.2 57L87.7 53.5L88.8 54.2L87.7 52.1L90.9 50.6L89.8 47.8L93 44.3L90.9 40.7L86.7 41.4L85.7 42.8L85.1 40.7L82.5 40.7L82.5 38.6L81 37.9L79.4 39.3L76.8 37.1L72.1 37.1L74.7 28.6L70.5 28.6L67.9 25.7L65.8 27.2L63.7 22.9L64.2 18.6L61.6 17.9L60.6 19.3L59.5 12.2L57.5 11.5L56.4 9.3L55.4 11.5L53.8 11.5L52.2 9.3L49.6 10.7L48.1 8.6L43.4 7.2L44.4 5.7L43.4 4.3L46 4.3L42.8 0L41.3 0L40.7 2.2L37.1 1.4L35.5 4.3L30.3 4.3L31.3 7.2L35.5 10.7L35.5 13.6L34 13.6L34.5 17.2L32.4 19.3L31.9 17.9L30.3 17.9L28.2 22.2L27.2 20.8L25.6 22.2L24.5 24.3L27.7 27.9L24.5 28.6L22.5 31.4L21.4 30L18.3 31.4L15.1 30.7L12.5 32.9L13.6 35L12 37.1L8.9 37.1L7.8 35.7L7.3 37.1L4.7 35L2.1 35L0 37.9L2.1 42.1L4.2 42.1L5.2 43.5L3.1 47.1L4.2 48.5L3.7 51.3L8.4 54.2L9.4 58.4L11.5 60.6L10.4 62L12.5 64.1L12 66.9L15.7 68.3L16.2 66.9L17.8 66.9L17.8 68.3L20.4 69L20.4 70.4L24 71.2L27.2 75.4L29.2 81L28.2 84.5L30.8 87.4L32.4 92.3L31.3 95.1L29.2 95.8L30.3 98.6Z', w: 93, h: 100, b: [20.01, -48.09, 21.79, -46.17] },
YE: { d: 'M89.4 0L78.1 2.1L61.8 3.9L53.1 8.3L47.6 15.5L46.3 18.8L43.6 20.5L41.9 20.5L39.5 17.2L35.9 17.7L33 17.5L26.6 16.7L24.9 15.7L18.9 15.9L16.3 15.7L14.7 15.9L14.5 16.3L14 15.9L13.4 16L13 16.7L11.7 16.6L11.3 16.2L10.3 16.3L8.4 14.5L7.8 14.3L6.7 14.7L6.1 15.2L6.3 15.4L6 16.1L7 17L5.7 17.4L5.9 17.9L5.2 18.3L5.3 19.1L6 19.2L6.1 19.8L5.5 19.7L5.6 20.6L5.1 20.7L5.1 21.1L6.4 22.1L6.2 22.2L6.4 22.5L5.9 22.6L6 23.6L5.1 23.3L4.9 24.7L3.6 24.8L3.2 25.1L3.4 26L1.5 26.1L1.8 26.4L2.3 29.3L2 31.2L0.9 32.9L1.1 33.8L1 34.3L1.7 35.2L2 37.1L1.6 37.7L1.5 37L1.4 37.2L1.1 36.8L0.8 36.9L1 36.3L0.9 36.2L0.8 37.3L0 37.3L0 37.6L0.3 38L0.9 37.6L2.5 38.3L2.6 39.6L3.3 40.6L3.2 41.7L2.7 41.1L2.8 40.6L2.6 41L3.3 41.9L3.7 43.2L4 45.3L3.5 44.8L3.5 44.6L4.6 47.5L4.8 48.9L5.3 50.1L6 50.7L6 51.4L6.6 52.7L6.1 57L8.5 61.1L8.3 62.2L8.6 62.3L8.3 62.2L8.2 62.6L8.8 62.5L9.6 61.9L12.5 62.8L12.6 63.3L13 63.4L13.8 63.3L14.7 62.7L15.1 63.1L14.7 63.1L15.8 63.1L16.9 62.5L17.3 62.6L18.3 61.5L19 61.2L20.1 61.5L20.2 62L21.1 61.7L21.3 62.2L21.7 62.2L22.1 61.8L21.3 61.6L21.9 61.7L22.1 61.2L22.7 61.2L22.9 60.9L23.2 61L22.9 61L23.1 61.5L22.5 61.6L23.2 61.9L23.4 60.7L24 59.7L25.1 59.1L26.5 58.9L29.1 56L32.4 55.3L35.1 55.2L36.6 55.5L38.1 55.1L39.1 55.2L41.2 54.1L45.5 53.2L48.2 50.7L49.7 50.3L51.2 49.1L52 49.1L52.9 49.5L53.1 49.9L54.2 49.8L54.5 49.4L55.6 49.4L55.8 49.7L56.6 49.2L58 49.2L59.6 47.9L59.8 47.2L60.1 47.2L60.3 46.7L60.9 46.5L60.9 45.6L61.8 44.5L62.5 44.6L62.6 44.2L64.3 43.3L69.7 41.3L70.5 41.3L70.7 41.6L72.1 41.4L72.4 41L75.5 39.4L78.4 38.9L83.4 37.5L84.8 36.8L86.2 36.6L86 36.1L86.5 35.7L87.5 35.3L88.1 35.4L88 35.1L88.4 34.8L91.6 33.6L91.2 32.3L91.4 31.7L91 31.3L90.9 29.9L92.1 27.3L93 26.3L94.2 25.5L100 23.5L97.2 17.2L96.5 17L96.9 16.4L89.4 0Z', w: 100, h: 63.4, b: [42.6, -19.36, 53.11, -12.69] },
ZA: { d: 'M21.6 17.4L21.6 42.4L18.9 43.1L18.4 44.4L17.1 44.6L17.3 45.7L16.6 46.1L13.9 45.2L10.5 45.8L9 44.8L5.8 44.4L6 43.4L5.4 42.8L5.8 42.3L5.7 41.4L4.6 41.2L4.5 40.1L3.8 39.6L2.6 40.1L2.3 41.3L1.8 41.4L2.2 42L1.8 42.9L1.2 42.8L0 43.8L2.3 47L6 57.7L11.1 66.8L11.6 69.6L11.3 71.9L10.2 73.2L9.2 72.6L8.5 73.5L8.8 75.1L9.6 74.9L10.2 76.2L9.4 75.3L9.1 75.6L12.1 79.8L12.4 81.2L11.7 81.5L11.4 83.2L12.5 84.7L12 83.1L14.2 82.6L14.7 83.2L14.5 84.9L15.8 84.6L16.7 85.3L17.9 85.1L17.4 85.1L17.8 86L17.3 86.7L18.1 86.5L19.4 87.8L21.7 88.2L24.3 85.8L26.9 85.5L26.6 85L27.4 84.7L29.4 85.3L30.7 84.7L32.7 84.8L33.6 83.7L34.7 83.4L34.7 82.7L37.1 82.1L42.3 82.9L42.5 82.1L44 81.9L51 83.6L51.9 81.9L55.9 82.4L56.3 82.2L55.8 81L56.8 80.1L61.1 80.4L64.8 78.6L73.4 71.9L79.1 65.1L82.4 62.6L85.8 57.8L91.2 47.9L96.4 43.9L98 40.5L100 31.6L94.6 31.3L94.5 34.8L93.1 34.8L89.4 34L88.3 32.6L87.8 31.1L87.3 31.4L87.3 29L89.5 25L91.1 23.9L93.9 25.8L94.5 25.4L94.2 24.7L94.6 23.4L94.5 14.4L93.9 12L91.9 8.9L91.8 6.6L90.4 1.9L89.6 1.3L84.2 1.5L80.4 0L76.5 0.6L75.9 2.2L73.4 3L72.3 2.9L68.7 7.3L67.8 7.2L67.4 8.3L66.2 8.4L65 9.2L64.5 10.2L64.2 10L63.4 14L61.7 15.1L60.6 16.7L57.2 17.4L57.4 18.5L55.1 23.7L54.1 24.2L52.2 23.9L51.4 24.5L49.9 24.6L48.1 24.2L46.9 23.2L45.4 23.4L42.6 20.9L39.9 21.1L38.9 22.3L38.2 25.9L37.3 27.1L36.3 27.3L34.9 28.4L34.1 30.1L32.5 30.3L31.9 31.6L28.4 31.7L26.9 31.2L25.7 32L25.2 29.2L26.8 26.7L26.7 25.4L25.5 23.4L25.7 22.1L24.9 21.6L24.5 20.1L21.6 17.4Z', w: 100, h: 88.2, b: [16.45, 22.7, 32.89, 37.2] },
ZM: { d: 'M12.2 82.8L20.6 81.3L21.8 81.9L23.3 81.6L25.4 82L26.4 83.4L28.4 84.6L30 84.9L31.6 84.3L33.4 86L34.9 85.9L34.9 85.4L36 85L39 86L40 86.8L43 85.7L45.4 82.4L47.4 80.8L49.8 76.7L58.2 72.5L58.5 68.5L59.2 67.6L59.9 67.7L61.6 66.1L64.6 65L71.9 64.9L71.4 64.3L71.6 62.3L70.5 61.4L70.2 59.3L96 50.5L95.1 50L94.2 51L93.1 48.9L92 48.6L92.7 48L92.2 47.4L91.1 47.2L92.7 46.3L94.1 43.6L93.8 42.4L94.2 40.9L93.4 39.8L94.1 38.6L94.5 38.2L95.6 38.6L96.5 37.6L97.1 37.9L98.6 36.2L97.1 36L96.2 34.3L96.8 31.2L96.6 29.4L95.9 29.4L95.9 27.9L96.5 28.1L97.4 25.6L96 23.3L96.8 22.7L98 22.7L100 20.5L98.8 19.4L98.7 17.6L96.7 16.3L97.2 14.7L95.9 13.4L95.8 12.3L95 12.1L94.6 12.8L94 12.3L94.2 11.2L93.5 11.1L93.6 10.4L91.8 9.3L90.1 9.2L89.2 7.9L85.3 7.6L84.7 6.2L82.7 6.2L81.8 5.5L81.7 4.3L80.1 3.4L77.4 3.6L74.9 0L58.8 2.4L59.4 4.1L58.8 5.4L56.4 7.9L54.5 9L55.8 10L55.6 10.8L56.1 11.8L56.5 11.7L56.5 13.1L57.2 13.8L56.2 17.5L57.2 21.5L55.9 22.9L54.5 29.2L55 31.5L57.7 32.8L60.2 36.4L62.2 36.1L63.9 37L64.3 36.7L63.7 35.9L63.9 35.2L64.6 34.5L65.7 35L66.7 34.3L66.7 45.7L65.2 45.5L64.9 44.9L65.6 43.6L64.8 43.5L61.3 45.7L60.8 45L59.8 45.4L58 41.3L56.8 40.1L56 40.8L55.1 39.4L55.8 38.6L55.1 37.4L54.1 36.6L52.7 36.3L52.3 36.8L51.8 35.9L50.8 36.1L50.6 35.1L48 35.3L46.7 33.5L46.7 32.4L44.7 31.1L44.5 29.1L42.9 29.6L42.8 31.6L41.7 32.9L39 32.9L37.9 32.2L37 32.6L34 32.5L33.2 31.3L31.7 31.4L31.3 30.6L29.9 31.1L29.6 30.2L28.4 29.7L28 27.3L28.6 26L23.5 26.9L22.1 28.2L20.8 28.3L19.5 27.6L20.6 26.4L20.3 24.9L18.4 24.6L18.2 23.6L17.1 23.3L17.8 27.8L17.3 28.2L17.4 28.9L16.7 30L17.3 31.4L16.9 34.5L17.8 36.3L16.7 37.5L16.1 40.3L17.6 41.7L0 41.7L0 70L1.1 71.5L1 72.5L4.3 75.4L6.8 78.6L8.5 79.5L9.7 81.3L12.2 82.8Z', w: 100, h: 86.8, b: [22, 8.23, 33.71, 18.39] },
ZW: { d: 'M52.9 89L56.1 88.1L58 88.2L61 89.4L62.3 90.5L63.9 90.4L64.5 91.2L71.6 90.3L74.5 91.1L75.9 90.8L77.7 92.1L91.6 76.9L92.7 77.2L90.9 74.5L93 71.3L92.5 67.8L93.6 66.4L94.9 66.5L96.7 63.9L97.4 62.7L97.4 60.4L98.3 60.3L98.1 59.4L99.5 59.4L99.9 55.9L98.6 55.1L98.9 54.2L96.9 54.4L97.2 51.8L96.3 51.7L96.3 50.1L97.3 49.1L97.6 46.3L97.2 45.5L95.5 45.6L95.3 44.8L95.8 44.4L95.5 42.9L98.3 42.3L98.6 41.2L97.6 38.7L99.5 38.2L99.4 37.4L100 36.6L99.4 36.1L99.2 34.2L98.3 32.3L98.9 31.1L98.6 29.3L99.4 29.3L99.7 27.1L98.6 24.9L99.7 23L99.1 22.6L99 20.9L96.9 17.4L98 17.2L99 14.5L96 14.6L95.3 14.2L95.4 13.2L89.8 10.9L85.2 10.6L85.1 9.6L83 8L78.9 7.3L77.4 5.3L74.3 5.2L73.7 6L72.7 5.2L66.2 5.3L66.2 0.1L65.4 0.7L64 0.3L63.6 0.8L63.1 0.1L58 0L55.9 0.8L55.2 0.4L54.5 1.1L50.7 2.1L48.1 4.5L47.1 4.4L46.1 5.7L45.7 11.7L38.4 14.9L33.1 18L29.5 24.1L26.6 26.4L23 31.4L19.2 32.2L18.4 33L17.5 32.7L17 31.8L12.8 30.8L12.5 30.3L10.9 30.9L10.9 31.6L8.6 31.9L7.9 31.6L7.8 30.5L5.9 29.3L3.4 30.1L1.1 29.7L0.3 29.1L0 30L0.6 32.4L2 33.5L3.8 37.4L6.9 40.2L7.4 43.1L9.3 44.5L9.7 45.8L9.2 46.8L10.9 49.5L11.9 52.8L14.3 53.6L13.9 54.4L14.7 54.4L15.2 55.5L16.9 55.9L17.5 57.3L25.2 60L26.3 62.8L26.2 65.7L28.9 65.3L31.8 66L31.3 73.8L34.1 76.6L35.4 80.4L40 80.8L41.5 81.7L42.8 81.2L46.2 83L48.9 83.5L48.3 84.8L48.5 86.4L49.6 87.2L51.5 87.2L52.9 89Z', w: 100, h: 92.1, b: [25.24, 15.81, 33.07, 23.02] },
}
/** The silhouette for an ISO-3166-1 alpha-2 code, or null if we have none. */
export function countryShape(code: string | null | undefined): CountryShape | null {
if (!code) return null
return COUNTRY_SHAPES[code.toUpperCase()] ?? null
}
+162
View File
@@ -0,0 +1,162 @@
/**
* Flags, drawn rather than typed.
*
* ── Why not the emoji ─────────────────────────────────────────────────────
*
* A regional-indicator pair is the obvious answer and works on macOS, Linux and
* Android. It does not work on Windows: Chrome there has no flag glyphs at all
* and falls back to drawing the two letters, so "DE" is what a Windows user
* sees. Since that is most users, and since a book element that renders as two
* capitals is not a flag, the emoji is not an option.
*
* ── Why not a flag icon set ───────────────────────────────────────────────
*
* Every country as an SVG is a few megabytes — more than the whole of Studio —
* to decorate a badge, and it would have to be lazily fetched at exactly the
* moment the print renderer cannot fetch anything.
*
* ── What this does instead ────────────────────────────────────────────────
*
* Most flags of the countries people put in travel books are constructions:
* equal stripes, a Nordic cross, a centred cross, a disc on a field. Those can
* be drawn exactly from a handful of numbers and a list of colours, and are
* listed here only where the construction is genuinely exact — a flag with a
* coat of arms, an emblem or unequal stripes is deliberately absent rather than
* approximated, because a wrong flag is worse than no flag.
*
* Anything not listed falls back to the country's silhouette, which
* `countryShapes.ts` has for 198 countries and which is never wrong.
*/
export type FlagSpec =
/** Equal horizontal bands, top to bottom. */
| { t: 'h'; c: string[] }
/** Equal vertical bands, left to right. */
| { t: 'v'; c: string[] }
/** Off-centre cross: field, cross, and an optional cross inside the cross. */
| { t: 'nordic'; field: string; cross: string; inner?: string }
/** Centred cross on a field. */
| { t: 'cross'; field: string; cross: string }
/** A disc on a plain field. */
| { t: 'disc'; field: string; disc: string }
/**
* The flags that can be drawn exactly.
*
* Ordered by region so a missing neighbour is easy to spot. Colours are the
* official specifications where the country publishes one.
*/
export const FLAG_SPECS: Record<string, FlagSpec> = {
// ── Nordic crosses ──────────────────────────────────────────────────────
IS: { t: 'nordic', field: '#02529c', cross: '#ffffff', inner: '#dc1e35' },
NO: { t: 'nordic', field: '#ba0c2f', cross: '#ffffff', inner: '#00205b' },
SE: { t: 'nordic', field: '#005293', cross: '#fecb00' },
DK: { t: 'nordic', field: '#c8102e', cross: '#ffffff' },
FI: { t: 'nordic', field: '#ffffff', cross: '#002f6c' },
// ── Horizontal bands ────────────────────────────────────────────────────
DE: { t: 'h', c: ['#000000', '#dd0000', '#ffce00'] },
NL: { t: 'h', c: ['#ae1c28', '#ffffff', '#21468b'] },
RU: { t: 'h', c: ['#ffffff', '#0039a6', '#d52b1e'] },
AT: { t: 'h', c: ['#ed2939', '#ffffff', '#ed2939'] },
HU: { t: 'h', c: ['#cd2a3e', '#ffffff', '#436f4d'] },
BG: { t: 'h', c: ['#ffffff', '#00966e', '#d62612'] },
LT: { t: 'h', c: ['#fdb913', '#006a44', '#c1272d'] },
EE: { t: 'h', c: ['#0072ce', '#000000', '#ffffff'] },
LU: { t: 'h', c: ['#ed2939', '#ffffff', '#00a1de'] },
AM: { t: 'h', c: ['#d90012', '#0033a0', '#f2a800'] },
SL: { t: 'h', c: ['#1eb53a', '#ffffff', '#0072c6'] },
YE: { t: 'h', c: ['#ce1126', '#ffffff', '#000000'] },
PL: { t: 'h', c: ['#ffffff', '#dc143c'] },
ID: { t: 'h', c: ['#ff0000', '#ffffff'] },
MC: { t: 'h', c: ['#ce1126', '#ffffff'] },
UA: { t: 'h', c: ['#005bbb', '#ffd500'] },
// ── Vertical bands ──────────────────────────────────────────────────────
FR: { t: 'v', c: ['#002395', '#ffffff', '#ed2939'] },
IT: { t: 'v', c: ['#008c45', '#f4f5f0', '#cd212a'] },
BE: { t: 'v', c: ['#000000', '#fae042', '#ed2939'] },
IE: { t: 'v', c: ['#169b62', '#ffffff', '#ff883e'] },
RO: { t: 'v', c: ['#002b7f', '#fcd116', '#ce1126'] },
ML: { t: 'v', c: ['#14b53a', '#fcd116', '#ce1126'] },
GN: { t: 'v', c: ['#ce1126', '#fcd116', '#009460'] },
CI: { t: 'v', c: ['#ff8200', '#ffffff', '#009e60'] },
TD: { t: 'v', c: ['#002664', '#fecb00', '#c60c30'] },
NG: { t: 'v', c: ['#008751', '#ffffff', '#008751'] },
PE: { t: 'v', c: ['#d91023', '#ffffff', '#d91023'] },
// ── Crosses and discs ───────────────────────────────────────────────────
CH: { t: 'cross', field: '#da291c', cross: '#ffffff' },
JP: { t: 'disc', field: '#ffffff', disc: '#bc002d' },
BD: { t: 'disc', field: '#006a4e', disc: '#f42a41' },
}
export interface FlagBand {
x: number
y: number
w: number
h: number
fill: string
}
/** A flag is drawn on a 30 × 20 box — the 3:2 most countries use. */
export const FLAG_W = 30
export const FLAG_H = 20
/**
* The rectangles a flag is made of, in draw order.
*
* Rectangles rather than paths because every construction here is rectangular,
* and a rectangle survives being scaled to a badge without a path parser. The
* disc is the one exception and comes back separately.
*/
export function flagBands(spec: FlagSpec): FlagBand[] {
if (spec.t === 'h') {
const h = FLAG_H / spec.c.length
return spec.c.map((fill, i) => ({ x: 0, y: i * h, w: FLAG_W, h, fill }))
}
if (spec.t === 'v') {
const w = FLAG_W / spec.c.length
return spec.c.map((fill, i) => ({ x: i * w, y: 0, w, h: FLAG_H, fill }))
}
if (spec.t === 'cross') {
// A fifth of the height, centred both ways.
const bar = FLAG_H / 5
return [
{ x: 0, y: 0, w: FLAG_W, h: FLAG_H, fill: spec.field },
{ x: (FLAG_W - bar) / 2, y: FLAG_H * 0.15, w: bar, h: FLAG_H * 0.7, fill: spec.cross },
{ x: FLAG_W / 2 - FLAG_H * 0.35, y: (FLAG_H - bar) / 2, w: FLAG_H * 0.7, h: bar, fill: spec.cross },
]
}
if (spec.t === 'nordic') {
// The upright sits left of centre — that offset is what makes it Nordic
// rather than a plain cross.
const bar = FLAG_H / 5
const upright = FLAG_W * 0.34
const out: FlagBand[] = [
{ x: 0, y: 0, w: FLAG_W, h: FLAG_H, fill: spec.field },
{ x: upright - bar / 2, y: 0, w: bar, h: FLAG_H, fill: spec.cross },
{ x: 0, y: (FLAG_H - bar) / 2, w: FLAG_W, h: bar, fill: spec.cross },
]
if (spec.inner) {
const thin = bar / 2
out.push(
{ x: upright - thin / 2, y: 0, w: thin, h: FLAG_H, fill: spec.inner },
{ x: 0, y: (FLAG_H - thin) / 2, w: FLAG_W, h: thin, fill: spec.inner },
)
}
return out
}
return [{ x: 0, y: 0, w: FLAG_W, h: FLAG_H, fill: spec.field }]
}
/** The disc on a disc flag, or null for every other construction. */
export function flagDisc(spec: FlagSpec): { cx: number; cy: number; r: number; fill: string } | null {
if (spec.t !== 'disc') return null
return { cx: FLAG_W / 2, cy: FLAG_H / 2, r: FLAG_H * 0.3, fill: spec.disc }
}
export function flagSpec(code: string | null | undefined): FlagSpec | null {
if (!code) return null
return FLAG_SPECS[code.toUpperCase()] ?? null
}
@@ -0,0 +1,92 @@
import type { BookElement, BookSpread } from '@trek/shared'
/**
* What colour a page number has to be to be readable.
*
* ── Why this is not one setting ──────────────────────────────────────────
*
* A book has dark pages and light ones, often on the same spread. One fixed
* folio colour is therefore wrong on half of them: the warm grey that reads
* beautifully on paper vanishes into a full-bleed night photograph, and white
* vanishes the moment the next page is white too. You end up either setting the
* colour per spread, which the document has no place for, or accepting that
* some numbers cannot be read.
*
* ── How it decides ───────────────────────────────────────────────────────
*
* By asking what is actually under the number. The renderer knows every element
* and where it sits, so it walks them from the top down and takes the first one
* that covers the spot:
*
* - a shape or a panel: its own fill, whose brightness is arithmetic
* - a photograph: unknowable without decoding it, and a photograph is dark far
* more often than not once it has been printed. White, with a whisper of a
* shadow, is the answer that works on all of them
* - nothing: the page colour
*
* It is a heuristic and it is meant to be — the alternative is sampling pixels
* from an image that may not have loaded, in a renderer that also has to run
* for print. Anyone it gets wrong can still set the colour by hand, which turns
* it off.
*/
/** Ink for a light page, and its opposite. Never pure black on paper. */
const DARK_INK = '#3f3a33'
const LIGHT_INK = '#ffffff'
export interface FolioInk {
color: string
/**
* A shadow, only over a photograph.
*
* White on an unknown picture is a coin toss on the pale ones, and a hairline
* of shadow costs nothing and settles it.
*/
shadow?: string
}
/** Perceived brightness, 0..1. Rec. 601 weights — good enough to pick an ink. */
export function brightness(hex: string): number {
const clean = hex.replace('#', '')
const full = clean.length === 3 ? clean.split('').map(c => c + c).join('') : clean
const n = parseInt(full.slice(0, 6), 16)
if (!Number.isFinite(n)) return 1
const r = (n >> 16) & 255
const g = (n >> 8) & 255
const b = n & 255
return (r * 0.299 + g * 0.587 + b * 0.114) / 255
}
/** Whether a point in spread coordinates falls inside an element's frame. */
function covers(el: BookElement, x: number, y: number): boolean {
const f = el.frame
return x >= f.x && x <= f.x + f.w && y >= f.y && y <= f.y + f.h
}
/**
* The ink for a folio at this point on the spread.
*
* Elements are walked from last to first, because the document's array order is
* paint order: the last one drawn is the one you see.
*/
export function folioInk(spread: BookSpread, x: number, y: number): FolioInk {
for (let i = spread.elements.length - 1; i >= 0; i--) {
const el = spread.elements[i]
if (el.opacity < 0.35 || !covers(el, x, y)) continue
if (el.kind === 'photo') {
return { color: LIGHT_INK, shadow: '0 0.3mm 0.6mm rgba(0,0,0,0.45)' }
}
if (el.kind === 'shape') {
// A shape with no fill is not what is under the number — whatever is
// behind it is, so keep looking.
if (!el.fill || el.fill === 'transparent') continue
return { color: brightness(el.fill) > 0.55 ? DARK_INK : LIGHT_INK }
}
// Text and the travel elements draw type on nothing, so they do not decide
// the background; the page underneath still does.
}
const paper = spread.background ?? '#ffffff'
return { color: brightness(paper) > 0.55 ? DARK_INK : LIGHT_INK }
}
+226
View File
@@ -0,0 +1,226 @@
import type { BookElement, BookPageSetup } from '@trek/shared'
/**
* Photo grids — a set of frames placed together, in one gesture.
*
* ── How this differs from a layout ────────────────────────────────────────
*
* A layout (`templates.ts`) rearranges the *whole spread*: it takes what is
* already there, pours it into a new arrangement and parks the rest. A grid
* adds a block of empty frames wherever you put it, and touches nothing else.
* You reach for a layout when you are deciding what a page is; you reach for a
* grid when you have decided, and want four pictures in a row on the left half.
*
* Canva keeps them apart for the same reason, and calling both "templates"
* would make the difference invisible at exactly the moment it matters.
*
* ── Why fractions ────────────────────────────────────────────────────────
*
* Cells are stored as fractions of the block, so one definition serves a grid
* dropped across a whole spread and the same grid dropped into a quarter of a
* page. The gutter is the exception: it is a real measurement in millimetres,
* because 2mm between two pictures is 2mm whether the block is 60mm or 300mm
* wide — scaling the gap with the block would make small grids look glued
* together and big ones look scattered.
*/
export interface GridCell {
/** Fractions of the block, 0..1. */
x: number
y: number
w: number
h: number
}
export interface GridDef {
id: string
cells: GridCell[]
}
/** `rows` of equal cells, each row split into that many columns. */
function rows(...counts: number[]): GridCell[] {
const h = 1 / counts.length
const out: GridCell[] = []
counts.forEach((cols, r) => {
for (let c = 0; c < cols; c++) out.push({ x: c / cols, y: r * h, w: 1 / cols, h })
})
return out
}
/** `cols` of equal columns, each split into that many rows. */
function cols(...counts: number[]): GridCell[] {
const w = 1 / counts.length
const out: GridCell[] = []
counts.forEach((n, c) => {
for (let r = 0; r < n; r++) out.push({ x: c * w, y: r / n, w, h: 1 / n })
})
return out
}
/**
* The catalogue.
*
* Ordered by how often a photo book actually wants them: one big picture, then
* pairs, then the asymmetric arrangements that carry a page, then the dense
* grids that end a chapter.
*/
export const GRIDS: GridDef[] = [
{ id: 'single', cells: [{ x: 0, y: 0, w: 1, h: 1 }] },
{ id: 'two-across', cells: rows(2) },
{ id: 'two-down', cells: cols(2) },
{ id: 'three-across', cells: rows(3) },
{ id: 'three-down', cells: cols(3) },
// One picture carrying the block, the rest in support. The workhorse of a
// travel page: a landscape wide enough to hold the eye, and the details
// underneath it.
{ id: 'hero-two', cells: [
{ x: 0, y: 0, w: 1, h: 0.62 },
{ x: 0, y: 0.62, w: 0.5, h: 0.38 },
{ x: 0.5, y: 0.62, w: 0.5, h: 0.38 },
] },
{ id: 'hero-three', cells: [
{ x: 0, y: 0, w: 1, h: 0.64 },
...[0, 1, 2].map(i => ({ x: i / 3, y: 0.64, w: 1 / 3, h: 0.36 })),
] },
{ id: 'two-hero', cells: [
{ x: 0, y: 0, w: 0.5, h: 0.36 },
{ x: 0.5, y: 0, w: 0.5, h: 0.36 },
{ x: 0, y: 0.36, w: 1, h: 0.64 },
] },
{ id: 'side-hero', cells: [
{ x: 0, y: 0, w: 0.62, h: 1 },
{ x: 0.62, y: 0, w: 0.38, h: 0.5 },
{ x: 0.62, y: 0.5, w: 0.38, h: 0.5 },
] },
{ id: 'side-hero-left', cells: [
{ x: 0, y: 0, w: 0.38, h: 0.5 },
{ x: 0, y: 0.5, w: 0.38, h: 0.5 },
{ x: 0.38, y: 0, w: 0.62, h: 1 },
] },
{ id: 'side-three', cells: [
{ x: 0, y: 0, w: 0.6, h: 1 },
...[0, 1, 2].map(i => ({ x: 0.6, y: i / 3, w: 0.4, h: 1 / 3 })),
] },
{ id: 'grid-4', cells: rows(2, 2) },
{ id: 'grid-6', cells: rows(3, 3) },
{ id: 'grid-9', cells: rows(3, 3, 3) },
{ id: 'grid-8', cells: rows(4, 4) },
{ id: 'one-two', cells: [
{ x: 0, y: 0, w: 0.5, h: 1 },
{ x: 0.5, y: 0, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
] },
{ id: 'two-one', cells: [
{ x: 0, y: 0, w: 0.5, h: 0.5 },
{ x: 0, y: 0.5, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0, w: 0.5, h: 1 },
] },
{ id: 'rows-1-2', cells: [
{ x: 0, y: 0, w: 1, h: 0.5 },
{ x: 0, y: 0.5, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0.5, w: 0.5, h: 0.5 },
] },
{ id: 'rows-2-1', cells: [
{ x: 0, y: 0, w: 0.5, h: 0.5 },
{ x: 0.5, y: 0, w: 0.5, h: 0.5 },
{ x: 0, y: 0.5, w: 1, h: 0.5 },
] },
{ id: 'rows-2-3', cells: [...rows(2).map(c => ({ ...c, h: 0.52 })),
...[0, 1, 2].map(i => ({ x: i / 3, y: 0.52, w: 1 / 3, h: 0.48 }))] },
{ id: 'rows-3-2', cells: [...[0, 1, 2].map(i => ({ x: i / 3, y: 0, w: 1 / 3, h: 0.48 })),
...rows(2).map(c => ({ ...c, y: 0.48, h: 0.52 }))] },
{ id: 'cols-1-2', cells: cols(1, 2) },
{ id: 'cols-2-1', cells: cols(2, 1) },
{ id: 'cols-2-1-2', cells: cols(2, 1, 2) },
{ id: 'cols-1-2-1', cells: cols(1, 2, 1) },
// A band across the middle: three pictures at the same height with room above
// and below for words. The arrangement a chapter opener usually wants.
{ id: 'band-three', cells: [0, 1, 2].map(i => ({ x: i / 3, y: 0.26, w: 1 / 3, h: 0.48 })) },
{ id: 'band-four', cells: [0, 1, 2, 3].map(i => ({ x: i / 4, y: 0.3, w: 0.25, h: 0.4 })) },
// Staggered: alternate cells drop by a fraction of their height, which is what
// stops a row of four squares reading as a contact sheet.
{ id: 'stagger-four', cells: [0, 1, 2, 3].map(i => ({
x: i / 4, y: i % 2 ? 0.14 : 0, w: 0.25, h: 0.86,
})) },
{ id: 'stagger-three', cells: [0, 1, 2].map(i => ({
x: i / 3, y: i === 1 ? 0.16 : 0, w: 1 / 3, h: 0.84,
})) },
{ id: 'filmstrip', cells: [0, 1, 2, 3, 4].map(i => ({ x: i / 5, y: 0.32, w: 0.2, h: 0.36 })) },
{ id: 'grid-12', cells: rows(4, 4, 4) },
]
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
/**
* Turn a grid into elements, inside a box on the page.
*
* The gutter is taken out of each cell rather than added between them, so the
* block occupies exactly the box it was given — a grid asked to fill the safe
* area fills the safe area, instead of overhanging it by one gutter.
*/
export function gridElements(
grid: GridDef,
box: { x: number; y: number; w: number; h: number },
gutter = 3,
radius = 0,
): BookElement[] {
const half = gutter / 2
return grid.cells.map(cell => {
const x = box.x + cell.x * box.w
const y = box.y + cell.y * box.h
const w = cell.w * box.w
const h = cell.h * box.h
// Only interior edges get half a gutter; the block's outer edge is the box.
const left = cell.x > 0.001 ? half : 0
const top = cell.y > 0.001 ? half : 0
const right = cell.x + cell.w < 0.999 ? half : 0
const bottom = cell.y + cell.h < 0.999 ? half : 0
return {
id: uid('p'),
kind: 'photo',
frame: {
x: x + left,
y: y + top,
w: Math.max(2, w - left - right),
h: Math.max(2, h - top - bottom),
},
rotation: 0,
opacity: 1,
locked: false,
photoId: null,
fit: 'cover',
focalX: 0.5,
focalY: 0.5,
radius,
filter: 'none',
mask: null,
frameStyle: 'none',
} as BookElement
})
}
/**
* The box a grid lands in when it is simply clicked rather than placed.
*
* The safe area of the page you are looking at, which is almost always what was
* meant — a grid that lands half in the gutter has to be dragged before it is
* any use.
*/
export function defaultGridBox(page: BookPageSetup, single: boolean): { x: number; y: number; w: number; h: number } {
const W = single ? page.pageWidth : page.pageWidth * 2
const margin = Math.max(page.safe, Math.min(W, page.pageHeight) * 0.075)
return {
x: margin,
y: margin,
w: W - margin * 2,
h: page.pageHeight - margin * 2,
}
}
@@ -0,0 +1,64 @@
import type { BookFrame } from '@trek/shared'
import { useSettingsStore } from '../../store/settingsStore'
import { attributionFor, staticMapUrl } from './mapTiles'
import { normalizeTileUrl } from '../../utils/tileUrl'
/**
* Which map sources this instance can actually offer.
*
* TREK already knows: an admin has configured a tile template, and possibly a
* Mapbox token and style, for the planner and Atlas. Studio reads the same
* settings rather than asking again — a second place to paste a token is a
* second place for it to be wrong.
*
* A source with nothing behind it is not offered. Showing "Mapbox" to someone
* with no token produces an element that renders grey, and no message anywhere
* explains why.
*/
export interface MapSourceOption {
id: 'vector' | 'tiles' | 'static'
/** i18n key for the name. */
labelKey: string
/** The tile template or static URL to freeze into the element. */
url: string
attribution: string
}
/** The default tile template, when the instance has not set its own. */
const DEFAULT_TILES = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
export function useMapSources(frame: BookFrame, points: { lat: number; lng: number }[]): MapSourceOption[] {
const settings = useSettingsStore(s => s.settings)
// Through the same normaliser the planner's map uses: a template saved
// before OSM dropped its shards still names a host that no longer exists.
const tiles = normalizeTileUrl((settings.map_tile_url || '').trim()) || DEFAULT_TILES
const out: MapSourceOption[] = [
{ id: 'vector', labelKey: 'journey.studio.mapSourceVector', url: '', attribution: '' },
{ id: 'tiles', labelKey: 'journey.studio.mapSourceTiles', url: tiles, attribution: attributionFor(tiles) },
]
// Millimetres to pixels at roughly 200dpi — enough for print without asking
// the API for more than it will give.
const token = (settings.mapbox_access_token || '').trim()
if (token) {
const url = staticMapUrl({
points,
style: settings.mapbox_style || '',
token,
width: frame.w * 7.9,
height: frame.h * 7.9,
})
if (url) {
out.push({
id: 'static',
labelKey: 'journey.studio.mapSourceStatic',
url,
attribution: attributionFor(url),
})
}
}
return out
}
+316
View File
@@ -0,0 +1,316 @@
/**
* Real map imagery for the book's map element.
*
* ── Why this is opt-in rather than the default ────────────────────────────
*
* The vector map draws country outlines from boundaries the server already
* bundles: nothing is fetched, nothing is licensed, and it prints sharp at any
* size because it is geometry. That is the right default for a book.
*
* But an outline of Iceland cannot show which road you took, and some books
* want that. So this exists — with the two costs stated rather than hidden:
* tiles are fetched when the page renders, and they are raster, cut for one
* zoom level. Printed much larger than they were fetched, they go soft.
*
* ── Attribution ──────────────────────────────────────────────────────────
*
* Not optional and not a setting. OpenStreetMap's licence requires credit, and
* a photo book is a published work — arguably more so than a web page, since
* nobody can add a footnote to it after it is bound. Every tiled map element
* carries the line, and it is written into the document when the element is
* placed so the renderer cannot lose it.
*/
/** A slippy-map tile, in the standard XYZ scheme. */
export interface Tile {
x: number
y: number
z: number
url: string
}
/** Web Mercator, normalised to 0..1 — the space tile indices are cut from. */
export function toMercatorUnit(lng: number, lat: number): { x: number; y: number } {
const clamped = Math.max(-85.0511, Math.min(85.0511, lat))
const rad = (clamped * Math.PI) / 180
return {
x: (lng + 180) / 360,
y: (1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2,
}
}
export interface TileView {
tiles: Tile[]
/** Where the tile grid sits, in the element's millimetres. */
originX: number
originY: number
/** One tile's drawn size, in millimetres. */
size: number
zoom: number
/**
* The grid's top-left tile index.
*
* Exposed because the route has to be projected onto *this* grid: the vector
* map fits itself to country outlines, and a line placed by that fit would
* sit somewhere other than on the road it followed.
*/
tileX0: number
tileY0: number
}
/** Where a coordinate lands inside a tile view, in the element's millimetres. */
export function projectOntoTiles(view: TileView, lng: number, lat: number): { x: number; y: number } {
const u = toMercatorUnit(lng, lat)
const scale = 2 ** view.zoom
return {
x: view.originX + (u.x * scale - view.tileX0) * view.size,
y: view.originY + (u.y * scale - view.tileY0) * view.size,
}
}
/** How many tiles across a printed map may use, before it is not worth it. */
const MAX_TILES = 8 * 8
/** Shard letters for templates that still carry `{s}`. */
const TILE_SHARDS = ['a', 'b', 'c']
/**
* Which tiles cover a set of points, and where to draw them.
*
* The zoom is chosen so the route fills the frame rather than so the tiles are
* a particular size: a map of one city and a map of a continent both want to
* be the size of the box they are in, and only the zoom differs.
*/
export function tileView(
points: { lat: number; lng: number }[],
frame: { w: number; h: number },
template: string,
fixedZoom: number | null,
/**
* The exact window to cover, when the caller has already worked one out.
*
* The map element fits its own view — to the stops, to the countries, with
* whatever padding it was given — and the tiles have to be cut to *that*, not
* to a second fit computed here from the same points by different rules. Two
* fits meant the picture and the route were of slightly different places, and
* on a map cut to a country's outline it meant the country was full of the
* wrong towns.
*/
extent?: { minLng: number; minLat: number; maxLng: number; maxLat: number },
): TileView | null {
if (!template || (!points.length && !extent)) return null
const corners = extent
? [
{ lat: extent.maxLat, lng: extent.minLng },
{ lat: extent.minLat, lng: extent.maxLng },
]
: points
const unit = corners.map(p => toMercatorUnit(p.lng, p.lat))
let minX = Math.min(...unit.map(u => u.x))
let maxX = Math.max(...unit.map(u => u.x))
let minY = Math.min(...unit.map(u => u.y))
let maxY = Math.max(...unit.map(u => u.y))
// A single stop has no extent, and a route along one line has none on one
// axis. Both need a window rather than a point.
const PAD = 0.012
if (maxX - minX < PAD) { const c = (minX + maxX) / 2; minX = c - PAD / 2; maxX = c + PAD / 2 }
if (maxY - minY < PAD) { const c = (minY + maxY) / 2; minY = c - PAD / 2; maxY = c + PAD / 2 }
// A given extent is already the window the caller wants; only a fit worked
// out from bare points needs air added around it.
const AIR = extent ? 1 : 1.12
const spanX = (maxX - minX) * AIR
const spanY = (maxY - minY) * AIR
const cx = (minX + maxX) / 2
const cy = (minY + maxY) / 2
/*
* The zoom at which the route spans the frame.
*
* At zoom z the world is 2^z tiles across, so the span in tiles is
* `span * 2^z`; solving for the zoom where that equals the frame measured in
* tiles gives the fit. Floor rather than round, so the route is inside the
* frame rather than just outside it.
*/
const fitZoom = () => {
const wanted = Math.min(
Math.log2(1 / Math.max(spanX, 1e-9)),
Math.log2(1 / Math.max(spanY, 1e-9)),
) + 1
return Math.max(0, Math.min(19, Math.floor(wanted)))
}
let zoom = fixedZoom ?? fitZoom()
/*
* ── Which tiles, and how big ──────────────────────────────────────────
*
* The old sum went the wrong way round: it took the tiles the route happens
* to sit on, and then sized them to the frame. Whenever the route's own box
* did not line up with the tile grid — which is nearly always — the grid was
* narrower than the frame on one side and the map ran out with paper showing
* beside it.
*
* So the frame decides. First how large a tile has to be drawn for the route
* to span the frame, then which tiles are inside the frame at that size,
* with the edges rounded outwards so the last row and column overhang rather
* than stop short. The result covers the frame by construction.
*/
let scale = 2 ** zoom
let size = 0
let x0 = 0, x1 = 0, y0 = 0, y1 = 0
const layOut = () => {
scale = 2 ** zoom
// Cover, not contain: a map with a margin of paper down one side is not a
// map, it is a mistake. Padding belongs to the fit, not to the grid.
size = Math.max(
frame.w / Math.max(spanX * scale, 1e-9),
frame.h / Math.max(spanY * scale, 1e-9),
)
const halfW = frame.w / 2 / size
const halfH = frame.h / 2 / size
x0 = Math.floor(cx * scale - halfW)
x1 = Math.ceil(cx * scale + halfW) - 1
y0 = Math.floor(cy * scale - halfH)
y1 = Math.ceil(cy * scale + halfH) - 1
}
// Back off until the grid is a size worth printing. A 12x12 grid is 144
// requests for a picture 60mm across.
layOut()
while (zoom > 0 && (x1 - x0 + 1) * (y1 - y0 + 1) > MAX_TILES) {
zoom -= 1
layOut()
}
// Drawn so the route's centre is the frame's centre.
const originX = frame.w / 2 - (cx * scale - x0) * size
const originY = frame.h / 2 - (cy * scale - y0) * size
const tiles: Tile[] = []
const wrap = (n: number) => ((n % scale) + scale) % scale
for (let y = y0; y <= y1; y++) {
// Past the poles there is no tile — the row simply does not exist.
if (y < 0 || y >= scale) continue
for (let x = x0; x <= x1; x++) {
tiles.push({
x: x - x0,
y: y - y0,
z: zoom,
url: template
.replace('{z}', String(zoom))
// Longitude wraps, so a route across the antimeridian keeps its tiles.
.replace('{x}', String(wrap(x)))
.replace('{y}', String(y))
.replace('{r}', '')
/*
* The shard letter, chosen from the tile's own coordinates.
*
* Left in the template it is not a placeholder, it is a hostname that
* does not resolve, and the map draws as a blank frame with a route
* across it. Derived from x and y rather than picked at random so a
* given tile always comes from the same host: the editor and the
* print run then hit the same cache instead of fetching the picture
* twice.
*/
.replace('{s}', TILE_SHARDS[Math.abs(x + y) % TILE_SHARDS.length]),
})
}
}
return { tiles, originX, originY, size, zoom, tileX0: x0, tileY0: y0 }
}
/**
* The credit a tile source requires.
*
* Matched on the host rather than configured, so an instance pointing at its
* own OSM mirror still credits OpenStreetMap — and anything unrecognised gets
* no invented attribution, since a wrong credit is worse than none.
*/
export function attributionFor(template: string): string {
const url = template.toLowerCase()
if (url.includes('openstreetmap')) return '© OpenStreetMap contributors'
if (url.includes('mapbox')) return '© Mapbox © OpenStreetMap'
if (url.includes('maptiler')) return '© MapTiler © OpenStreetMap'
if (url.includes('stadiamaps')) return '© Stadia Maps © OpenStreetMap'
if (url.includes('carto')) return '© CARTO © OpenStreetMap'
if (url.includes('esri') || url.includes('arcgisonline')) return '© Esri'
return ''
}
/**
* A whole map as one image, from Mapbox's static API.
*
* The alternative to a tile grid for the styles that only exist as GL styles:
* one request, one picture, and the route can be drawn into it as an overlay.
* Needs the token the instance is already configured with.
*/
/**
* Styles the Static Images API cannot draw, and what to use instead.
*
* Mapbox's newer styles are 3D GL styles: they are assembled in the browser
* from vector tiles, lighting and model layers, and the static renderer simply
* refuses them — `standard` answers 400 with "Unsupported rasterarray tileset
* format", which arrives as a picture that never loads and no explanation
* anywhere.
*
* An instance configured for the planner's map is therefore usually configured
* with a style this cannot use, through no fault of whoever set it up. Falling
* back to the classic equivalent gives them a map that looks like the one they
* chose, rather than an empty frame.
*/
const STATIC_STYLE: Record<string, string> = {
'mapbox/standard': 'mapbox/streets-v12',
'mapbox/standard-satellite': 'mapbox/satellite-streets-v12',
}
/**
* Repair a static URL that was frozen into a document before the fallback
* above existed.
*
* The URL is stored rather than rebuilt at render time, on purpose, so a map
* placed last week keeps looking the way it did. That also means a map placed
* with a style the static renderer refuses stays broken for good unless it is
* fixed on the way out — and "delete it and place it again" is not a repair
* anyone should have to be told about.
*/
export function usableStaticUrl(url: string): string {
for (const [gl, classic] of Object.entries(STATIC_STYLE)) {
if (url.includes(`/styles/v1/${gl}/static/`)) {
return url.replace(`/styles/v1/${gl}/static/`, `/styles/v1/${classic}/static/`)
}
}
return url
}
export function staticMapUrl(opts: {
points: { lat: number; lng: number }[]
style: string
token: string
/** Pixels. The API caps each side at 1280. */
width: number
height: number
}): string | null {
if (!opts.token || !opts.points.length) return null
// mapbox://styles/mapbox/standard -> mapbox/standard
const asked = opts.style.replace(/^mapbox:\/\/styles\//, '') || 'mapbox/streets-v12'
const style = STATIC_STYLE[asked] ?? asked
const lats = opts.points.map(p => p.lat)
const lngs = opts.points.map(p => p.lng)
const bbox = [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)]
const w = Math.max(64, Math.min(1280, Math.round(opts.width)))
const h = Math.max(64, Math.min(1280, Math.round(opts.height)))
// @2x because this is going into print: a 1x static image at book size is
// roughly 96dpi, which is visibly soft on paper.
return `https://api.mapbox.com/styles/v1/${style}/static/`
+ `[${bbox.map(n => n.toFixed(5)).join(',')}]/${w}x${h}@2x`
+ `?padding=24&access_token=${encodeURIComponent(opts.token)}`
}
@@ -0,0 +1,92 @@
/**
* Book formats, in millimetres, because that is the language a print shop
* speaks.
*
* These are photo-book sizes, not paper sizes. A4 is a stationery standard and
* only some vendors print books in it; what CEWE, Saal and Polarsteps actually
* bind is mostly square — Polarsteps' own book is 21 × 21, which is why that is
* the default here. A5 landscape is the small, cheap format, 30 × 30 the big
* coffee-table one.
*
* Trim is the finished page; bleed is what the guillotine takes; safe is the
* margin nothing important may cross. A spread is two pages side by side, so its
* drawing width is twice the page width.
*/
export interface PagePreset {
id: PagePresetId
/** One page, millimetres. */
pageWidthMm: number
pageHeightMm: number
/** What gets drawn — a spread is two pages wide. */
widthMm: number
heightMm: number
bleedMm: number
safeMm: number
labelKey: string
}
export type PagePresetId =
| 'square-210'
| 'square-300'
| 'a4-landscape'
| 'a4-portrait'
| 'a5-landscape'
/**
* Any width and height at all.
*
* The five presets are what CEWE, Saal and Polarsteps actually bind, which
* covers almost everyone — but "almost" is doing real work there. A local
* printer with a 24 x 18 press, a book meant to sit in a particular shelf,
* a format a vendor lists that nobody else does: the picker either has a way
* to say that or those books cannot be made here at all.
*/
| 'custom'
function preset(id: PagePresetId, w: number, h: number, labelKey: string): PagePreset {
return {
id,
pageWidthMm: w,
pageHeightMm: h,
widthMm: w * 2,
heightMm: h,
bleedMm: 3,
safeMm: 5,
labelKey,
}
}
export const PAGE_PRESETS: Record<PagePresetId, PagePreset> = {
'square-210': preset('square-210', 210, 210, 'journey.studio.formatSquare21'),
'square-300': preset('square-300', 300, 300, 'journey.studio.formatSquare30'),
'a4-landscape': preset('a4-landscape', 297, 210, 'journey.studio.formatA4Landscape'),
'a4-portrait': preset('a4-portrait', 210, 297, 'journey.studio.formatA4Portrait'),
'a5-landscape': preset('a5-landscape', 210, 148, 'journey.studio.formatA5Landscape'),
// The dimensions here are only the starting point the fields open on; the
// document keeps whatever the user types.
custom: preset('custom', 210, 210, 'journey.studio.formatCustom'),
}
/**
* What a page may measure, in millimetres.
*
* The floor is a passport-sized book; the ceiling is what a large-format press
* takes, and also what keeps a spread inside the schema two-decimal
* millimetre field without the editor scaling to a smear.
*/
export const PAGE_MIN_MM = 60
export const PAGE_MAX_MM = 500
export function clampPageSize(value: number): number {
if (!Number.isFinite(value)) return PAGE_MIN_MM
return Math.min(PAGE_MAX_MM, Math.max(PAGE_MIN_MM, Math.round(value * 10) / 10))
}
/** Square first: it is what a photo book usually is. Custom last, as a way out. */
export const PAGE_PRESET_ORDER: PagePresetId[] = [
'square-210',
'square-300',
'a4-landscape',
'a4-portrait',
'a5-landscape',
'custom',
]
@@ -0,0 +1,22 @@
/**
* A colour per person, derived rather than stored.
*
* The same colour in everybody's window and the same colour tomorrow, with
* nothing written down and nothing to allocate. Two people can collide on a
* palette of eight, which is why the name rides along on the pointer's label.
*
* Its own file because PeerCursors.tsx exports components: a module that mixes
* the two breaks React Fast Refresh, and the pointers are exactly the kind of
* thing you edit while looking at them. Same reason bookRender.ts sits beside
* SpreadView.tsx.
*/
/** Distinct at a glance, and white type is legible on every one. */
const PEER_COLOURS = [
'#2563eb', '#c2410c', '#059669', '#7c3aed',
'#db2777', '#0891b2', '#a16207', '#4f46e5',
] // theme-lint-disable — identity colours, not app chrome
export function peerColour(userId: number): string {
return PEER_COLOURS[Math.abs(userId) % PEER_COLOURS.length]
}
+269
View File
@@ -0,0 +1,269 @@
/**
* Handing the book to the printer.
*
* ── Why the browser prints it ────────────────────────────────────────────
*
* The alternative was a renderer on the server — headless Chromium, or a PDF
* library drawing the pages a second time. Both were rejected for the same
* reason: TREK is self-hosted, and neither Chromium in the image nor a parallel
* renderer to proofread against is a cost worth carrying when the browser
* already produces exactly what the editor shows. It is also how TREK's other
* exports work, so this is the house style rather than a new one.
*
* What comes out is a real PDF: vector text, embedded fonts, images at full
* resolution. What does not come out is PDF metadata and the TrimBox/BleedBox
* entries — a browser sets neither. Which is why the sheets carry crop marks:
* a press reads where to cut from the marks, and has done for rather longer
* than the PDF format has existed.
*
* ── Why an iframe rather than a window ───────────────────────────────────
*
* Same reason as the Journey export: `window.open` is blocked by Safari on iOS
* inside an async callback, and `window.close` does not work reliably in a
* standalone PWA. An overlay with a `srcdoc` iframe works everywhere.
*/
/*
* The only literal colours in Studio, and deliberately so.
*
* The print view is a document of its own — a `srcdoc` iframe that inherits
* none of the app's tokens — and paper is white whatever theme the editor is
* in. Naming them here keeps the stylesheets below readable and puts the
* exception in one place instead of on every rule.
*/
const PAPER = '#ffffff' // theme-lint-disable — paper, not app chrome
const WORKTOP = '#52525b' // theme-lint-disable — the grey the sheets sit on
const BAR = '#0f172a' // theme-lint-disable — the print view's own header
const BAR_LINE = '#e4e4e7' // theme-lint-disable — and the line under it
/** Everything the print document needs, gathered before the iframe exists. */
export interface PrintSheetsInput {
/** The rendered sheets, as HTML. */
html: string
/** Sheet size in millimetres, bleed and crop-mark room included. */
sheetWidth: number
sheetHeight: number
/**
* The size of a one-page sheet, when the document also holds two-page ones.
*
* Spread mode mixes them: the covers are single leaves and everything between
* is a spread. Omitted when every sheet is the same size, which is the whole
* of page mode.
*/
singleWidth?: number
singleHeight?: number
title: string
/** Chrome for the overlay itself. */
labels: { save: string; close: string; count: string; preparing: string }
}
/**
* Every stylesheet the app is currently using, as markup for the iframe head.
*
* Copied rather than re-listed. The sheets are the editor's own components, so
* they need the editor's own CSS — including the `@font-face` rules for the
* seven bundled families, which is the difference between a book that prints in
* the typeface it was proofread in and one that prints in a substitute.
*
* In a build that is one `<link>`; under Vite it is a pile of inline `<style>`
* tags. Both are handled by taking the DOM as it is rather than guessing.
*/
function collectStyles(): string {
const out: string[] = []
for (const node of Array.from(document.querySelectorAll('style, link[rel="stylesheet"]'))) {
if (node.tagName === 'LINK') {
const href = (node as HTMLLinkElement).href
if (href) out.push(`<link rel="stylesheet" href="${escapeAttr(href)}">`)
} else {
out.push(`<style>${node.textContent ?? ''}</style>`)
}
}
return out.join('\n')
}
function escapeAttr(value: string): string {
return value.replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;')
}
/**
* A page box for the single-page sheets, when the document mixes widths.
*
* A named page rule, which is how CSS says "these elements print on a different
* sheet size". The alternative was making every sheet as wide as the widest,
* and that laid a cover in the middle of a spread-sized page with white either
* side — which is not a cover, it is a cover with a mistake around it.
*/
function singleRule(input: PrintSheetsInput): string {
if (!input.singleWidth || !input.singleHeight) return ''
if (input.singleWidth === input.sheetWidth && input.singleHeight === input.sheetHeight) return ''
return ` @page single { size: ${input.singleWidth}mm ${input.singleHeight}mm; margin: 0; }
.bx-sheet.is-single { page: single; }`
}
function escapeText(value: string): string {
return value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
/**
* Open the print view.
*
* Resolves once the document is on screen and ready to print — which is *after*
* its images and fonts have loaded, not merely after the markup is in place.
* Printing early is how a book comes out with blank rectangles where the
* photographs were.
*/
export function printSheets(input: PrintSheetsInput): () => void {
const doc = `<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>${escapeText(input.title)}</title>
<base href="${escapeAttr(window.location.origin)}/">
${collectStyles()}
<style>
/*
* One page rule for every sheet — see BookSheetsView on why they are all the
* same size. No margin: the sheet already carries its own bleed and marks,
* and a printer margin on top would scale the whole thing down to fit.
*/
@page { size: ${input.sheetWidth}mm ${input.sheetHeight}mm; margin: 0; }
${singleRule(input)}
/*
* On screen the sheets are scaled to fit the window; on paper they are not.
*
* A spread sheet is over 400 mm across, which at 1:1 is wider than any
* preview pane — the book ran off the side and had to be scrolled sideways to
* read it. The --bx-fit factor is set from outside the frame (nothing runs
* inside it) and only ever shrinks, so a small book is still shown at its
* real size. zoom rather than transform: it scales the layout rather than
* painting over it, so the page does not reserve the unscaled height.
*
* (No backticks in here — this whole stylesheet is a template literal.)
*/
@media screen { .bx-book { zoom: var(--bx-fit, 1); } }
html, body { margin: 0; padding: 0; background: ${WORKTOP}; }
.bx-book { display: flex; flex-direction: column; align-items: center; gap: 16px; padding: 16px 0; }
.bx-sheet { box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
@media print {
html, body { background: ${PAPER}; }
.bx-book { display: block; gap: 0; padding: 0; }
.bx-sheet { box-shadow: none; }
/* Photographs are the point. Without this the browser drops backgrounds
and colour-managed fills to save toner, which is right for a web page
and wrong for a photo book. */
* { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
}
</style>
</head>
<body>${input.html}</body>
</html>`
const overlay = document.createElement('div')
overlay.id = 'bx-overlay'
overlay.style.cssText =
'position:fixed;inset:0;background:rgba(0,0,0,0.75);z-index:9999;display:flex;align-items:center;justify-content:center;padding:8px;'
const style = document.createElement('style')
style.textContent = `
#bx-overlay .bx-card { width:100%;max-width:1100px;height:95vh;background:${PAPER};border-radius:12px;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 20px 60px rgba(0,0,0,0.35); }
#bx-overlay .bx-head { display:flex;align-items:center;justify-content:space-between;gap:8px;padding:8px 16px;border-bottom:1px solid ${BAR_LINE};flex-shrink:0;background:${BAR}; }
#bx-overlay .bx-name { min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12px;color:rgba(255,255,255,0.45);font-weight:500;letter-spacing:0.03em; }
#bx-overlay .bx-actions { display:flex;align-items:center;gap:8px;flex:none; }
#bx-overlay .bx-btn { min-height:44px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:600;cursor:pointer;font-family:inherit;white-space:nowrap; }
#bx-overlay .bx-btn[disabled] { opacity:0.5;cursor:default; }
#bx-overlay .bx-save { border:none;background:${PAPER};color:${BAR}; }
#bx-overlay .bx-close { border:1px solid rgba(255,255,255,0.15);background:rgba(255,255,255,0.1);color:rgba(255,255,255,0.7); }
@media (max-width: 640px) {
#bx-overlay { padding:0; }
#bx-overlay .bx-card { height:100dvh;max-width:none;border-radius:0; }
#bx-overlay .bx-name { display:none; }
#bx-overlay .bx-actions { flex:1; }
#bx-overlay .bx-btn { flex:1;padding:10px 12px;font-size:13px; }
}
`
const card = document.createElement('div')
card.className = 'bx-card'
const header = document.createElement('div')
header.className = 'bx-head'
header.innerHTML = `
<span class="bx-name">${escapeText(input.title)} &middot; ${escapeText(input.labels.count)}</span>
<div class="bx-actions">
<button id="bx-save" class="bx-btn bx-save" disabled>${escapeText(input.labels.preparing)}</button>
<button id="bx-close" class="bx-btn bx-close">${escapeText(input.labels.close)}</button>
</div>
`
const iframe = document.createElement('iframe')
iframe.style.cssText = `flex:1;width:100%;border:none;background:${WORKTOP};`
// Nothing inside the document runs — printing is triggered from here through
// contentWindow.print() — so allow-scripts is deliberately withheld.
iframe.setAttribute('sandbox', 'allow-same-origin allow-modals')
iframe.srcdoc = doc
card.appendChild(header)
card.appendChild(iframe)
overlay.appendChild(style)
overlay.appendChild(card)
document.body.appendChild(overlay)
const close = () => { stopFitting(); overlay.remove() }
overlay.onclick = e => { if (e.target === overlay) close() }
header.querySelector<HTMLButtonElement>('#bx-close')!.onclick = close
const save = header.querySelector<HTMLButtonElement>('#bx-save')!
save.onclick = () => { iframe.contentWindow?.print() }
/** CSS millimetres are defined against 96dpi, so this ratio is exact. */
const PX_PER_MM = 96 / 25.4
/** Shrink the preview until a sheet fits across the pane. Never enlarge. */
const fitPreview = () => {
const root = iframe.contentDocument?.documentElement
if (!root) return
const available = iframe.clientWidth - 32
if (available <= 0) return
const scale = Math.min(1, available / (input.sheetWidth * PX_PER_MM))
root.style.setProperty('--bx-fit', String(Math.round(scale * 1000) / 1000))
}
window.addEventListener('resize', fitPreview)
const stopFitting = () => window.removeEventListener('resize', fitPreview)
iframe.addEventListener('load', () => {
fitPreview()
void whenReady(iframe).then(() => {
save.disabled = false
save.textContent = input.labels.save
})
})
return close
}
/**
* When the document is actually printable.
*
* Both halves matter and neither is covered by the load event: a photograph
* still decoding prints as an empty box, and a face still loading prints in the
* fallback — the exact substitution the bundled typefaces exist to prevent.
*/
async function whenReady(iframe: HTMLIFrameElement): Promise<void> {
const win = iframe.contentWindow
const doc = iframe.contentDocument
if (!win || !doc) return
const images = Array.from(doc.images).map(img =>
img.complete
? Promise.resolve()
: new Promise<void>(resolve => {
// A picture that will not load must not hold the button hostage —
// better a book with one gap than a dialog that never finishes.
img.addEventListener('load', () => resolve(), { once: true })
img.addEventListener('error', () => resolve(), { once: true })
}),
)
await Promise.all([...images, doc.fonts?.ready ?? Promise.resolve()])
}
+500
View File
@@ -0,0 +1,500 @@
import type { BookShapeId } from '@trek/shared'
/**
* Every shape Studio can draw, as a path on a 0..100 square.
*
* ── Why paths and not clip-paths ──────────────────────────────────────────
*
* The first four shapes drew as `<div>`s: a rectangle is a box, an ellipse is a
* box with a radius, a triangle was a `clip-path`. That works for four shapes
* and stops working at five — a clipped div cannot take a stroke (the clip cuts
* the border in half, lengthwise), and a heart is not a box with a radius.
*
* So everything past the rectangle and the ellipse is an SVG path. Those two
* keep their old rendering, because a box genuinely is better as a box: exact
* corner radii, an even border, and no regression on documents that already
* exist.
*
* ── Why 0..100 and not a viewBox ──────────────────────────────────────────
*
* The obvious way to fit a path to a frame is `viewBox="0 0 100 100"` with
* `preserveAspectRatio="none"`, and it is wrong for a book. That scales the
* coordinate system, and the stroke scales with it — a 0.5mm outline on a frame
* twice as wide as it is tall comes out 0.7mm on the sides and 0.35mm top and
* bottom. On screen you might not notice. Printed at 300dpi next to a straight
* rule, you do.
*
* Instead the *points* are scaled and the coordinate system is left in
* millimetres, so a stroke width means one thing everywhere. That is what
* `scalePath` does, and it is the reason every path here is written in a
* deliberately boring subset: absolute `M`, `L`, `C`, `Q`, `Z`, every number an
* x or a y in strict alternation. No arcs — `A` carries radii and a rotation
* flag that do not survive a non-uniform scale — and no relative commands.
*
* Curves are cubics because a cubic through a quarter circle is exact to within
* 0.03%, which is far below what a printer resolves.
*/
/** Circle-to-cubic constant: the handle length that makes a quarter arc. */
const K = 0.5523
const r2 = (n: number) => Math.round(n * 100) / 100
/**
* Stretch a ring of points to fill the 0..100 box exactly.
*
* A regular hexagon inscribed in a circle is 86.6 units wide and 100 tall, and
* drawn that way it would leave 7 units of nothing down each side of its own
* frame. In an editor that is a real problem rather than a cosmetic one: the
* selection outline, the resize handles and every snap target follow the frame,
* so a shape that does not fill its frame cannot be aligned to anything you can
* see. Filling the box is also what Canva does, and it costs a regularity
* nobody can spot — a hexagon in a square frame still reads as a hexagon.
*/
function fit(points: [number, number][]): string {
const xs = points.map(p => p[0])
const ys = points.map(p => p[1])
const minX = Math.min(...xs), maxX = Math.max(...xs)
const minY = Math.min(...ys), maxY = Math.max(...ys)
const sx = maxX > minX ? 100 / (maxX - minX) : 1
const sy = maxY > minY ? 100 / (maxY - minY) : 1
return `M${points
.map(([x, y]) => `${r2((x - minX) * sx)} ${r2((y - minY) * sy)}`)
.join('L')}Z`
}
/** A regular polygon, first point at the top, stretched to fill the box. */
function polygon(sides: number, rotation = -90): string {
const pts: [number, number][] = []
for (let i = 0; i < sides; i++) {
const a = ((rotation + (360 / sides) * i) * Math.PI) / 180
pts.push([50 + 50 * Math.cos(a), 50 + 50 * Math.sin(a)])
}
return fit(pts)
}
/**
* A star, `points` tips alternating with `points` valleys.
*
* `inner` is the valley radius as a fraction of the tip radius, and it is the
* only number that decides whether a star looks like a star or like a gear:
* roughly 0.38 for five points, higher as the count grows, or the tips get so
* thin they disappear at print size.
*/
function star(points: number, inner: number, rotation = -90): string {
const pts: [number, number][] = []
for (let i = 0; i < points * 2; i++) {
const rad = i % 2 === 0 ? 50 : 50 * inner
const a = ((rotation + (180 / points) * i) * Math.PI) / 180
pts.push([50 + rad * Math.cos(a), 50 + rad * Math.sin(a)])
}
return fit(pts)
}
/**
* A scalloped edge — a wax seal, a sticker, a burst.
*
* Not a star with a soft inner radius: the lobes have to bulge *outwards*
* between the points, which needs a curve through each valley rather than a
* straight line to it.
*/
function scallop(lobes: number, depth: number): string {
const step = (Math.PI * 2) / lobes
/*
* The petal tip is the curve's midpoint, not its control point.
*
* A quadratic at t=0.5 sits a quarter of the way to each end and half the way
* to the control, so the furthest the outline actually reaches is
* `0.5·R·cos(Δ/2) + 0.5·rm` — well short of `rm` itself. Sizing from `rm`
* would draw a flower that overflows its box by a tenth and gets clipped;
* solving for that midpoint instead makes the drawn petals touch the edge
* exactly, which is what "normalised to 0..100" has to mean.
*/
const reach = 0.5 * Math.cos(step / 2) + 0.5 * (1 + depth)
const R = 50 / reach
const rm = R * (1 + depth)
let d = ''
for (let i = 0; i < lobes; i++) {
const a0 = -Math.PI / 2 + step * i
const a1 = a0 + step
const am = (a0 + a1) / 2
const x0 = r2(50 + R * Math.cos(a0)), y0 = r2(50 + R * Math.sin(a0))
const x1 = r2(50 + R * Math.cos(a1)), y1 = r2(50 + R * Math.sin(a1))
const xm = r2(50 + rm * Math.cos(am)), ym = r2(50 + rm * Math.sin(am))
d += i === 0 ? `M${x0} ${y0}` : ''
d += `Q${xm} ${ym} ${x1} ${y1}`
}
return `${d}Z`
}
/** A circle as four cubics. */
function circle(cx = 50, cy = 50, rx = 50, ry = 50): string {
const kx = rx * K, ky = ry * K
return `M${r2(cx)} ${r2(cy - ry)}`
+ `C${r2(cx + kx)} ${r2(cy - ry)} ${r2(cx + rx)} ${r2(cy - ky)} ${r2(cx + rx)} ${r2(cy)}`
+ `C${r2(cx + rx)} ${r2(cy + ky)} ${r2(cx + kx)} ${r2(cy + ry)} ${r2(cx)} ${r2(cy + ry)}`
+ `C${r2(cx - kx)} ${r2(cy + ry)} ${r2(cx - rx)} ${r2(cy + ky)} ${r2(cx - rx)} ${r2(cy)}`
+ `C${r2(cx - rx)} ${r2(cy - ky)} ${r2(cx - kx)} ${r2(cy - ry)} ${r2(cx)} ${r2(cy - ry)}Z`
}
/** A rounded rectangle, corner radius in the same 0..100 units. */
function roundRect(r: number, x = 0, y = 0, w = 100, h = 100): string {
const k = r * (1 - K)
return `M${r2(x + r)} ${r2(y)}L${r2(x + w - r)} ${r2(y)}`
+ `C${r2(x + w - k)} ${r2(y)} ${r2(x + w)} ${r2(y + k)} ${r2(x + w)} ${r2(y + r)}`
+ `L${r2(x + w)} ${r2(y + h - r)}`
+ `C${r2(x + w)} ${r2(y + h - k)} ${r2(x + w - k)} ${r2(y + h)} ${r2(x + w - r)} ${r2(y + h)}`
+ `L${r2(x + r)} ${r2(y + h)}`
+ `C${r2(x + k)} ${r2(y + h)} ${r2(x)} ${r2(y + h - k)} ${r2(x)} ${r2(y + h - r)}`
+ `L${r2(x)} ${r2(y + r)}`
+ `C${r2(x)} ${r2(y + k)} ${r2(x + k)} ${r2(y)} ${r2(x + r)} ${r2(y)}Z`
}
/**
* The shape table.
*
* `rect` and `ellipse` are here for the panel previews and for masking; the
* page renderer still draws those two as boxes. Everything else is drawn from
* exactly this path, in the editor and in the print.
*/
export const SHAPE_PATHS: Record<BookShapeId, string> = {
// ── Basics ──────────────────────────────────────────────────────────────
rect: 'M0 0L100 0L100 100L0 100Z',
ellipse: circle(),
line: 'M0 50L100 50Z',
triangle: 'M50 0L100 100L0 100Z',
'triangle-down': 'M0 0L100 0L50 100Z',
diamond: 'M50 0L100 50L50 100L0 50Z',
parallelogram: 'M22 0L100 0L78 100L0 100Z',
trapezoid: 'M22 0L78 0L100 100L0 100Z',
pentagon: polygon(5),
hexagon: polygon(6),
'hexagon-flat': polygon(6, 0),
heptagon: polygon(7),
octagon: polygon(8, -112.5),
capsule: roundRect(50),
squircle: roundRect(26),
/** A doorway: square-footed, semicircular head. Reads as a portal or a niche. */
arch: `M0 100L0 50C0 ${r2(50 - 50 * K)} ${r2(50 - 50 * K)} 0 50 0`
+ `C${r2(50 + 50 * K)} 0 100 ${r2(50 - 50 * K)} 100 50L100 100Z`,
'half-circle': `M0 100C0 ${r2(100 - 100 * K)} ${r2(50 - 50 * K)} 0 50 0`
+ `C${r2(50 + 50 * K)} 0 100 ${r2(100 - 100 * K)} 100 100Z`,
'quarter-circle': `M0 100L0 0C${r2(100 * K)} 0 100 ${r2(100 - 100 * K)} 100 100Z`,
// ── Stars ───────────────────────────────────────────────────────────────
'star-4': star(4, 0.3),
'star-5': star(5, 0.382),
'star-6': star(6, 0.5),
'star-8': star(8, 0.58),
'star-12': star(12, 0.72),
burst: star(16, 0.78),
seal: scallop(12, 0.055),
/**
* The four-pointed twinkle. Concave sides, not straight ones — the straight
* version is a diamond with dents, and only the inward curve reads as light.
*/
sparkle: 'M50 0Q56 44 100 50Q56 56 50 100Q44 56 0 50Q44 44 50 0Z',
// ── Arrows ──────────────────────────────────────────────────────────────
'arrow-right': 'M0 30L58 30L58 5L100 50L58 95L58 70L0 70Z',
'arrow-left': 'M100 30L42 30L42 5L0 50L42 95L42 70L100 70Z',
'arrow-up': 'M30 100L30 42L5 42L50 0L95 42L70 42L70 100Z',
'arrow-down': 'M30 0L30 58L5 58L50 100L95 58L70 58L70 0Z',
'arrow-both': 'M0 50L28 8L28 32L72 32L72 8L100 50L72 92L72 68L28 68L28 92Z',
'chevron-right': 'M8 0L58 0L100 50L58 100L8 100L50 50Z',
'chevron-left': 'M92 0L42 0L0 50L42 100L92 100L50 50Z',
/** An arrow that turns a corner — a route that changes direction. */
'arrow-bent': 'M0 62L0 90L62 90L62 100L100 76L62 52L62 62Z',
// ── Speech ──────────────────────────────────────────────────────────────
/*
* A body and a tail, and the tail is wound the *same way round* as the body.
*
* Under the default non-zero fill rule two subpaths that overlap cancel where
* they meet if their windings oppose — which drew the tail as a wedge of hole
* punched through the bubble, joined to nothing. Nothing about the shape is
* wrong; the direction the points are listed in is.
*/
'bubble-round': `${roundRect(22, 0, 0, 100, 74)}M26 74L46 74L26 100Z`,
'bubble-square': 'M0 0L100 0L100 74L46 74L26 100L26 74L0 74Z',
'bubble-oval': `${circle(50, 37, 50, 37)}M28 66L48 71L22 96Z`,
'bubble-think': `${circle(52, 34, 48, 34)}${circle(24, 80, 12, 12)}${circle(8, 95, 6, 5)}`,
// ── Decoration ──────────────────────────────────────────────────────────
/**
* A heart with a real point at the bottom rather than a rounded chin. The two
* lobes are cubics that meet at the top notch; the tangents at the base are
* steep, which is what stops it reading as a spade.
*/
heart: 'M50 100C50 100 0 66 0 30C0 10 16 0 30 0C40 0 46 6 50 14'
+ 'C54 6 60 0 70 0C84 0 100 10 100 30C100 66 50 100 50 100Z',
cloud: 'M18 90C6 90 0 80 0 70C0 60 8 52 18 52C18 34 32 22 48 22'
+ 'C62 22 74 30 78 44C90 44 100 54 100 66C100 80 90 90 78 90Z',
'cloud-puffy': 'M14 88C4 88 0 78 4 70C-2 60 6 48 18 50C20 34 34 24 48 28'
+ 'C56 14 78 14 84 30C96 28 104 40 98 52C106 62 100 82 86 88Z',
drop: 'M50 0C50 0 88 42 88 66C88 86 71 100 50 100C29 100 12 86 12 66C12 42 50 0 50 0Z',
/** A crescent: one circle with a second bitten out of it, as a single path. */
moon: `M62 0C28 0 0 22 0 50C0 78 28 100 62 100C72 100 82 98 90 94`
+ `C64 90 44 72 44 50C44 28 64 10 90 6C82 2 72 0 62 0Z`,
/**
* A disc with rays. One star with a generous valley radius rather than a
* circle plus spikes: two overlapping subpaths would need a fill rule to
* decide whether the middle is a disc or a hole, and this needs no rule.
*/
sun: star(12, 0.68),
'flower-5': scallop(5, 0.28),
'flower-6': scallop(6, 0.24),
leaf: 'M50 0C82 18 100 44 100 66C100 86 78 100 50 100C22 100 0 86 0 66C0 44 18 18 50 0Z',
cross: 'M36 0L64 0L64 36L100 36L100 64L64 64L64 100L36 100L36 64L0 64L0 36L36 36Z',
plus: 'M42 0L58 0L58 42L100 42L100 58L58 58L58 100L42 100L42 58L0 58L0 42L42 42Z',
shield: 'M50 0L100 16L100 52C100 78 78 94 50 100C22 94 0 78 0 52L0 16Z',
/**
* A cog. Twelve teeth cut by a scalloped outer edge with a hole through the
* middle — the hole is a second subpath wound the same way, which `fill-rule:
* evenodd` turns into a hole.
*/
gear: `${star(12, 0.76)}${circle(50, 50, 20, 20)}`,
/** A torn ticket: notched at both ends, the way a stub is perforated. */
ticket: 'M0 8L100 8L100 34C93 34 88 41 88 50C88 59 93 66 100 66L100 92L0 92L0 66'
+ 'C7 66 12 59 12 50C12 41 7 34 0 34Z',
wave: 'M0 30Q25 0 50 30Q75 60 100 30L100 70Q75 100 50 70Q25 40 0 70Z',
mountain: 'M0 100L34 34L52 62L66 44L100 100Z',
/** A compass rose — four long points, four short, as one star of eight. */
compass: star(8, 0.24),
/** A map pin: round head, drawn tail. */
pin: 'M50 100C50 100 88 56 88 36C88 16 71 0 50 0C29 0 12 16 12 36C12 56 50 100 50 100Z',
// ── Organic ─────────────────────────────────────────────────────────────
//
// Hand-drawn blobs, four of them, each asymmetric on purpose: a symmetric
// blob reads as a badly drawn circle. They are the shape a modern travel page
// uses behind a caption or as a photo frame that is not a rectangle.
'blob-1': 'M52 2C74 0 96 14 99 36C102 58 88 78 68 90C48 102 22 98 10 82'
+ 'C-2 66 0 42 8 26C16 10 30 4 52 2Z',
'blob-2': 'M38 0C66 -2 92 16 98 40C104 64 88 88 62 96C36 104 10 92 3 70'
+ 'C-4 48 4 24 18 12C26 5 30 1 38 0Z',
'blob-3': 'M50 0C78 0 100 20 100 48C100 76 80 100 52 100C24 100 0 82 0 54'
+ 'C0 26 22 0 50 0Z',
'blob-4': 'M30 4C56 -6 88 6 96 30C104 54 92 82 68 94C44 106 16 96 6 74'
+ 'C-4 52 4 14 30 4Z',
// ── Banners ─────────────────────────────────────────────────────────────
'banner-ribbon': 'M0 20L100 20L100 80L0 80L14 50Z',
'banner-pennant': 'M0 0L100 0L100 62L50 100L0 62Z',
'banner-bookmark': 'M0 0L100 0L100 100L50 68L0 100Z',
'banner-flag': 'M0 0L100 0L86 30L100 60L0 60Z',
}
/**
* The box a path's ink actually occupies.
*
* Not the extent of its numbers: a curve never reaches its control points, so
* measuring those would report a heart as wider than it is drawn. Cubic and
* quadratic extrema are solved rather than sampled — it is barely more code
* than stepping along the curve and it is exact, which matters when the answer
* is used to normalise every shape in the library.
*/
function pathBounds(path: string): { x: number; y: number; w: number; h: number } {
const nums = (path.match(/-?\d*\.?\d+/g) ?? []).map(Number)
const cmds = path.match(/[MLCQZ]/g) ?? []
let i = 0
let cx = 0
let cy = 0
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
const hit = (x: number, y: number) => {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
/** Where a cubic turns on one axis: the roots of its derivative in 0..1. */
const cubicExtrema = (p0: number, p1: number, p2: number, p3: number): number[] => {
const a = -p0 + 3 * p1 - 3 * p2 + p3
const b = 2 * (p0 - 2 * p1 + p2)
const c = p1 - p0
if (Math.abs(a) < 1e-9) return Math.abs(b) < 1e-9 ? [] : [-c / b]
const disc = b * b - 4 * a * c
if (disc < 0) return []
const root = Math.sqrt(disc)
return [(-b + root) / (2 * a), (-b - root) / (2 * a)]
}
const cubicAt = (t: number, p0: number, p1: number, p2: number, p3: number) => {
const u = 1 - t
return u * u * u * p0 + 3 * u * u * t * p1 + 3 * u * t * t * p2 + t * t * t * p3
}
for (const cmd of cmds) {
if (cmd === 'Z') continue
if (cmd === 'M' || cmd === 'L') {
cx = nums[i++]; cy = nums[i++]
hit(cx, cy)
continue
}
// A quadratic is a cubic whose controls sit two thirds of the way out.
let x1: number, y1: number, x2: number, y2: number, x: number, y: number
if (cmd === 'Q') {
const qx = nums[i++], qy = nums[i++]
x = nums[i++]; y = nums[i++]
x1 = cx + (2 / 3) * (qx - cx); y1 = cy + (2 / 3) * (qy - cy)
x2 = x + (2 / 3) * (qx - x); y2 = y + (2 / 3) * (qy - y)
} else {
x1 = nums[i++]; y1 = nums[i++]
x2 = nums[i++]; y2 = nums[i++]
x = nums[i++]; y = nums[i++]
}
hit(x, y)
for (const t of cubicExtrema(cx, x1, x2, x)) {
if (t > 0 && t < 1) hit(cubicAt(t, cx, x1, x2, x), cubicAt(t, cy, y1, y2, y))
}
for (const t of cubicExtrema(cy, y1, y2, y)) {
if (t > 0 && t < 1) hit(cubicAt(t, cx, x1, x2, x), cubicAt(t, cy, y1, y2, y))
}
cx = x; cy = y
}
return { x: minX, y: minY, w: maxX - minX, h: maxY - minY }
}
/**
* Normalise a path so its ink fills the 0..100 box exactly.
*
* Applied to every shape in the table, which turns "the paths are normalised"
* from a rule someone has to remember while hand-drawing a cloud into something
* true by construction.
*
* It has to be true, because the editor draws the selection outline, the resize
* handles and every snap target from the element's *frame*. A shape that
* occupies 84% of its frame cannot be aligned against anything you can see: you
* snap the box to a margin and the drawing sits somewhere inside it. Filling the
* box costs a little regularity — a hexagon in a square frame is very slightly
* wide — and buys an editor where what you snap is what you get.
*/
function normalise(path: string): string {
const b = pathBounds(path)
// A rule has no height and a shape with no extent has nothing to fit.
const sx = b.w > 1e-9 ? 100 / b.w : 1
const sy = b.h > 1e-9 ? 100 / b.h : 1
const ox = b.w > 1e-9 ? b.x : 0
const oy = b.h > 1e-9 ? b.y : 0
let axis = 0
return path.replace(/-?\d*\.?\d+/g, m => {
const isX = axis++ % 2 === 0
const v = isX ? (Number(m) - ox) * sx : (Number(m) - oy) * sy
return String(Math.round(v * 100) / 100)
})
}
for (const key of Object.keys(SHAPE_PATHS) as BookShapeId[]) {
SHAPE_PATHS[key] = normalise(SHAPE_PATHS[key])
}
/**
* Fit a 0..100 path to a box of `w` × `h`, in the box's own units.
*
* Scaling the numbers instead of the coordinate system is the whole point — see
* the note at the top of this file. Every path in the table is written so that
* numbers alternate x, y from the start of each command, which makes this a
* scan rather than a parse.
*/
export function scalePath(path: string, w: number, h: number): string {
const sx = w / 100
const sy = h / 100
let axis = 0
return path.replace(/-?\d*\.?\d+/g, m => {
const v = Number(m) * (axis++ % 2 === 0 ? sx : sy)
return String(Math.round(v * 1000) / 1000)
})
}
/**
* The same path on the 0..1 square SVG clip paths use.
*
* `clipPathUnits="objectBoundingBox"` is what lets one clip follow a frame as it
* is resized without re-rendering the path, which is what a photo frame has to
* do. It *does* stretch the shape with the box — a heart on a wide frame is a
* wide heart — and that is correct here: a frame you cannot make wide is an
* ornament, not a frame.
*/
export function unitPath(path: string): string {
return path.replace(/-?\d*\.?\d+/g, m =>
String(Math.round((Number(m) / 100) * 100000) / 100000))
}
/**
* Shapes drawn as two subpaths where the inner one is meant to be a hole.
*
* Only these get `fill-rule: evenodd`. Everywhere else the default matters:
* `bubble-think`'s trailing dots and `bubble-round`'s tail are separate
* subpaths too, and under `evenodd` any overlap between them and the body
* would knock a hole in it.
*/
export const HOLED_SHAPES = new Set<BookShapeId>(['gear'])
export interface ShapeGroup {
/** i18n key under `journey.studio.shapeGroup`. */
id: string
shapes: BookShapeId[]
}
/**
* How the panel is divided.
*
* Grouped the way you go looking for them — "I want an arrow", "I want
* something round and soft" — rather than by how they are built. Sixty
* unlabelled tiles in one grid is a wall; six groups of ten is a menu.
*/
export const SHAPE_GROUPS: ShapeGroup[] = [
{
id: 'basic',
shapes: ['rect', 'squircle', 'ellipse', 'capsule', 'triangle', 'triangle-down',
'diamond', 'parallelogram', 'trapezoid', 'arch', 'half-circle', 'quarter-circle'],
},
{
id: 'polygons',
shapes: ['pentagon', 'hexagon', 'hexagon-flat', 'heptagon', 'octagon'],
},
{
id: 'stars',
shapes: ['star-4', 'star-5', 'star-6', 'star-8', 'star-12', 'burst', 'seal', 'sparkle'],
},
{
id: 'arrows',
shapes: ['arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'arrow-both',
'chevron-right', 'chevron-left', 'arrow-bent'],
},
{
id: 'speech',
shapes: ['bubble-round', 'bubble-square', 'bubble-oval', 'bubble-think'],
},
{
id: 'travel',
shapes: ['pin', 'compass', 'mountain', 'sun', 'moon', 'cloud', 'cloud-puffy',
'drop', 'wave', 'leaf', 'shield', 'ticket'],
},
{
id: 'decor',
shapes: ['heart', 'flower-5', 'flower-6', 'cross', 'plus', 'gear',
'blob-1', 'blob-2', 'blob-3', 'blob-4'],
},
{
id: 'banners',
shapes: ['banner-ribbon', 'banner-pennant', 'banner-bookmark', 'banner-flag'],
},
]
/**
* The shapes offered as picture frames.
*
* A subset, and the subset is the point: a photograph cut to a speech bubble's
* tail or to a hairline is a photograph you cannot read. What is here holds an
* image at book size — broad, mostly convex, no thin limbs.
*/
export const FRAME_SHAPES: BookShapeId[] = [
'rect', 'squircle', 'ellipse', 'capsule', 'arch', 'half-circle',
'triangle', 'diamond', 'pentagon', 'hexagon', 'octagon',
'heart', 'star-5', 'star-6', 'seal', 'flower-5', 'flower-6',
'blob-1', 'blob-2', 'blob-3', 'blob-4',
'shield', 'drop', 'leaf', 'pin', 'banner-pennant', 'banner-bookmark',
]
+219
View File
@@ -0,0 +1,219 @@
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { bookElementSchema } from '@trek/shared'
/**
* A spread as a file, so a design can leave the book it was made in.
*
* ── Why this is worth having ─────────────────────────────────────────────
*
* A page somebody designs is worth more than one book. Without a way out it
* exists once, in one journey, belonging to one person; with one it can be
* posted, copied, argued about and improved. That is the whole feature — the
* format is deliberately dull so the sharing is easy.
*
* ── Why the measurements are fractions ───────────────────────────────────
*
* Divided by the page they were drawn on, exactly as the auto layout's
* templates are (see bookTemplates.data.ts), so a spread designed on a 210mm
* square lands correctly in an A4 landscape book. A file carrying millimetres
* would only fit the trim size it came from, which is not a thing anyone would
* find out until it landed wrong.
*
* ── What is not in it ────────────────────────────────────────────────────
*
* Photographs. A spread file carries the *design* — the frames, the panels, the
* type — and the frames come out empty. Anything else would mean shipping
* someone's pictures inside a template, which is neither what the sender means
* by "here is my layout" nor something the receiver's journey has any use for.
*
* Map URLs, in both directions, and this one is not about tidiness:
*
* - **Out**: a static map element freezes the URL it was rendered from, and
* for a Mapbox instance that URL carries the instance's access token. A
* file meant to be posted in a forum must not have a credential in it.
* - **In**: a tile template from a stranger is a URL every reader's browser
* will fetch, tile by tile, the moment the page is opened — an IP address
* handed to whoever wrote the file. The receiving instance's own map
* settings fill the element back in instead.
*
* ── What an imported file is trusted for ─────────────────────────────────
*
* Nothing. It is a stranger's JSON, and it is treated as one:
*
* - Read locally. The file is never uploaded; it is parsed in the tab that
* chose it, and only the resulting document — which the server validates
* against the same contract — is ever sent anywhere.
* - Parsed, not evaluated. `JSON.parse`, never `eval`, and the result goes
* through the document contract element by element, so unknown fields are
* dropped and every known one is range-checked. Colours in particular are
* `#rrggbb` or nothing, which is what keeps a "colour" from being a CSS
* expression that fetches something.
* - Bounded. A size limit on the file and a count limit on the elements,
* because the damage a hostile file can do here is not code execution —
* text is rendered as text, never as HTML — it is handing the editor more
* than it or the server will accept, and the failure mode of *that* is a
* document that will not save.
*/
/** What the file says it is, so a stray JSON is refused rather than half-read. */
const MAGIC = 'trek.studio.spread'
const VERSION = 1
/**
* As many elements as the document contract allows on one spread.
*
* Kept in step with `bookSpreadSchema` by hand, which is a copy — but the cost
* of it drifting is a file that imports and then will not save, and the cost of
* not having it is the same file locking the editor up while it tries.
*/
const MAX_ELEMENTS = 60
/**
* A spread with no photographs in it is a few kilobytes. A megabyte is already
* far past anything this format produces, and the point of the limit is to fail
* on a hostile or mistaken file before parsing it, not to be generous.
*/
export const MAX_SPREAD_FILE_BYTES = 1024 * 1024
/** The document contract's colour rule, applied before the contract sees it. */
const HEX = /^#[0-9a-fA-F]{6}$/
export interface SpreadFile {
format: typeof MAGIC
version: number
/** Free text from whoever exported it. Shown nowhere yet; kept for later. */
name?: string
background: string | null
/** Frames and type sizes as fractions — see the note above. */
elements: unknown[]
}
const round4 = (n: number) => Math.round(n * 10000) / 10000
const round2 = (n: number) => Math.round(n * 100) / 100
/**
* Turn a spread into a file's worth of JSON.
*
* Photographs are dropped to empty frames on the way out, and the ids go with
* them: an id is only meaningful inside the document it came from.
*/
export function exportSpread(spread: BookSpread, page: BookPageSetup, name?: string): SpreadFile {
const PW = page.pageWidth
const PH = page.pageHeight
const elements = spread.elements.map(el => {
const out: Record<string, unknown> = {
...el,
frame: {
x: round4(el.frame.x / PW),
y: round4(el.frame.y / PH),
w: round4(el.frame.w / PW),
h: round4(el.frame.h / PH),
},
}
delete out.id
if (typeof out.size === 'number') out.size = round4(out.size / PH)
if (typeof out.radius === 'number') out.radius = round4(out.radius / PW)
if (typeof out.strokeWidth === 'number') out.strokeWidth = round4(out.strokeWidth / PW)
// The design, not the pictures.
if (out.kind === 'photo') out.photoId = null
/*
* And not the map source. For a Mapbox instance this string contains the
* access token the map was rendered with — see the note at the top. The
* receiving instance fills it in from its own settings.
*/
if (out.kind === 'map') { out.tileUrl = ''; out.attribution = '' }
return out
})
return { format: MAGIC, version: VERSION, name, background: spread.background, elements }
}
/**
* Read a file back into a spread for this book's page size.
*
* Returns null for anything that is not one of ours. Every element goes through
* the document contract on the way in, so a file with a field this build does
* not know — or one somebody edited by hand — cannot put a broken element into
* a document.
*/
export function importSpread(raw: unknown, page: BookPageSetup): BookSpread | null {
if (!raw || typeof raw !== 'object') return null
const file = raw as Partial<SpreadFile>
if (file.format !== MAGIC || !Array.isArray(file.elements)) return null
const PW = page.pageWidth
const PH = page.pageHeight
// Past the contract's own limit the document would import and then refuse to
// save, which is a worse outcome than refusing it here.
const wanted = file.elements.slice(0, MAX_ELEMENTS)
const elements = wanted.flatMap((el, i) => {
if (!el || typeof el !== 'object') return []
const e = el as Record<string, unknown>
const f = e.frame as { x?: number; y?: number; w?: number; h?: number } | undefined
if (!f) return []
const scaled: Record<string, unknown> = {
...e,
id: `i-${Date.now().toString(36)}-${i}`,
frame: {
x: round2((f.x ?? 0) * PW),
y: round2((f.y ?? 0) * PH),
w: round2((f.w ?? 0.2) * PW),
h: round2((f.h ?? 0.2) * PH),
},
}
if (typeof scaled.size === 'number') scaled.size = round2(scaled.size * PH)
if (typeof scaled.radius === 'number') scaled.radius = round2(scaled.radius * PW)
if (typeof scaled.strokeWidth === 'number') scaled.strokeWidth = round2(scaled.strokeWidth * PW)
/*
* A map arrives with no source, whatever the file says.
*
* The contract would accept any 500-character string here, and the browser
* would then fetch tiles from it — so a shared template could quietly ask
* everyone who opens it to call on a server of the author's choosing. The
* element comes in blank and picks the instance's own map up from the
* source picker, which is one click and reveals nothing.
*/
if (scaled.kind === 'map') { scaled.tileUrl = ''; scaled.attribution = '' }
// Through the contract: a file is someone else's data, and an element that
// will not parse is dropped rather than let into the document.
const parsed = bookElementSchema.safeParse(scaled)
return parsed.success ? [parsed.data] : []
})
if (elements.length === 0) return null
return {
id: `sp-${Date.now().toString(36)}`,
role: 'inner',
// Checked here as well as in the contract: this one field is the only part
// of the file that is not run through an element schema on its way in.
background: typeof file.background === 'string' && HEX.test(file.background) ? file.background : null,
elements,
parked: [],
entryId: null,
}
}
/** Hand the file to the browser as a download. */
export function downloadSpread(spread: BookSpread, page: BookPageSetup, name: string) {
const json = JSON.stringify(exportSpread(spread, page, name), null, 2)
const url = URL.createObjectURL(new Blob([json], { type: 'application/json' }))
const a = document.createElement('a')
a.href = url
a.download = `${slug(name) || 'spread'}.trekspread.json`
document.body.appendChild(a)
a.click()
a.remove()
// Revoked on the next tick: revoking synchronously can cancel the download
// in some browsers before it has started reading the blob.
setTimeout(() => URL.revokeObjectURL(url), 0)
}
function slug(name: string): string {
return name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 40)
}
+447
View File
@@ -0,0 +1,447 @@
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
/**
* Layout templates for a spread.
*
* A template is not a special mode and not a locked page — it is a set of frames
* with the pictures left out. Applying one keeps the photos and the words that
* are already on the spread and pours them into the new arrangement, in order;
* anything the new layout has room for but the old one did not fill stays an
* empty frame you can drop a picture onto.
*
* Content the new layout has no room for is **parked, not deleted**. Trying a
* text-only layout and then changing your mind must not cost you the pictures:
* they come straight back the moment a layout with frames is applied. Undo would
* technically get them back too, but "I clicked the wrong card" should not need
* a rescue.
*
* That is the difference between a template system that helps and one that
* fights you: nothing here is remembered as a mode. Once applied, the spread is
* an ordinary spread.
*/
export interface Template {
id: string
labelKey: string
/** How many pictures the layout has room for. */
photoSlots: number
/**
* Which spreads this arrangement is for.
*
* A cover is one page rather than two, and it is the one page where the type
* is the subject — a layout built for a spread lands half of itself in the
* fold, so the two sets have to be kept apart. Everything without this is an
* inner layout, which is what all of them were.
*/
role?: 'inner' | 'single'
/** Draw the frames for a page of this size. */
build: (page: BookPageSetup) => TemplateSlot[]
}
export type TemplateSlot =
| { kind: 'photo'; frame: Frame }
| { kind: 'heading'; frame: Frame }
| { kind: 'body'; frame: Frame }
| { kind: 'meta'; frame: Frame }
interface Frame { x: number; y: number; w: number; h: number }
const M = 16
const G = 6
function grid(page: BookPageSetup, cols: number, rows: number, opts: { bleed?: boolean } = {}): Frame[] {
const W = page.pageWidth * 2
const H = page.pageHeight
const pad = opts.bleed ? -page.bleed : M
const availW = (opts.bleed ? W + page.bleed * 2 : W - M * 2) - G * (cols - 1)
const availH = (opts.bleed ? H + page.bleed * 2 : H - M * 2) - G * (rows - 1)
const cw = availW / cols
const ch = availH / rows
const out: Frame[] = []
for (let r = 0; r < rows; r++) {
for (let c = 0; c < cols; c++) {
out.push({ x: pad + c * (cw + G), y: pad + r * (ch + G), w: cw, h: ch })
}
}
return out
}
/** The spread layouts. `COVER_TEMPLATES` holds the single-page set. */
export const TEMPLATES: Template[] = [
{
id: 'hero-story',
labelKey: 'journey.studio.tpl.heroStory',
photoSlots: 4,
build: page => {
const W = page.pageWidth
const H = page.pageHeight
const col = W + M + 6
const colW = W - M * 2 - 6
const gridH = H * 0.24
return [
// Exactly up to the gutter — see the note in autoLayout.ts.
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
{ kind: 'meta', frame: { x: col, y: M + 18, w: colW, h: 5 } },
{ kind: 'heading', frame: { x: col, y: M + 26, w: colW, h: 12 } },
{ kind: 'body', frame: { x: col, y: M + 44, w: colW, h: H - M * 2 - 44 - gridH - 10 } },
{ kind: 'photo', frame: { x: col, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH } },
{ kind: 'photo', frame: { x: col + (colW - G * 2) / 3 + G, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH } },
{ kind: 'photo', frame: { x: col + ((colW - G * 2) / 3 + G) * 2, y: H - M - gridH, w: (colW - G * 2) / 3, h: gridH } },
]
},
},
{
id: 'full-bleed',
labelKey: 'journey.studio.tpl.fullBleed',
photoSlots: 1,
build: page => [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth * 2 + page.bleed * 2, h: page.pageHeight + page.bleed * 2 } },
{ kind: 'heading', frame: { x: M, y: page.pageHeight - M - 22, w: page.pageWidth - M, h: 12 } },
],
},
{
id: 'two-up',
labelKey: 'journey.studio.tpl.twoUp',
photoSlots: 2,
build: page => {
const H = page.pageHeight
const W = page.pageWidth
return [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
{ kind: 'photo', frame: { x: W, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
]
},
},
{
id: 'grid-4',
labelKey: 'journey.studio.tpl.grid4',
photoSlots: 4,
build: page => grid(page, 2, 2).map(frame => ({ kind: 'photo' as const, frame })),
},
{
id: 'grid-6',
labelKey: 'journey.studio.tpl.grid6',
photoSlots: 6,
build: page => grid(page, 3, 2).map(frame => ({ kind: 'photo' as const, frame })),
},
{
id: 'strip',
labelKey: 'journey.studio.tpl.strip',
photoSlots: 3,
build: page => {
const H = page.pageHeight
const W = page.pageWidth * 2
const stripH = H * 0.52
const cw = (W + page.bleed * 2 - G * 2) / 3
return [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: cw, h: stripH + page.bleed } },
{ kind: 'photo', frame: { x: -page.bleed + cw + G, y: -page.bleed, w: cw, h: stripH + page.bleed } },
{ kind: 'photo', frame: { x: -page.bleed + (cw + G) * 2, y: -page.bleed, w: cw, h: stripH + page.bleed } },
{ kind: 'meta', frame: { x: M, y: stripH + 14, w: page.pageWidth - M * 2, h: 5 } },
{ kind: 'heading', frame: { x: M, y: stripH + 22, w: page.pageWidth - M * 2, h: 12 } },
{ kind: 'body', frame: { x: page.pageWidth + M, y: stripH + 14, w: page.pageWidth - M * 2, h: H - stripH - 14 - M } },
]
},
},
{
id: 'quiet-text',
labelKey: 'journey.studio.tpl.quietText',
photoSlots: 0,
build: page => {
const W = page.pageWidth
const H = page.pageHeight
return [
{ kind: 'meta', frame: { x: W + M, y: M + 26, w: W - M * 2, h: 5 } },
{ kind: 'heading', frame: { x: W + M, y: M + 34, w: W - M * 2, h: 14 } },
{ kind: 'body', frame: { x: W + M, y: M + 54, w: W - M * 2, h: H - M * 2 - 54 } },
]
},
},
{
id: 'full-text',
labelKey: 'journey.studio.tpl.fullText',
photoSlots: 1,
build: page => {
// A picture that owns the left page and a column of story on the right,
// set narrow. The arrangement for an entry with one photograph worth
// looking at and something to say about it.
const W = page.pageWidth
const H = page.pageHeight
return [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: H + page.bleed * 2 } },
{ kind: 'meta', frame: { x: W + M, y: H * 0.28, w: W - M * 2, h: 5 } },
{ kind: 'heading', frame: { x: W + M, y: H * 0.28 + 8, w: W - M * 2, h: 14 } },
{ kind: 'body', frame: { x: W + M, y: H * 0.28 + 26, w: (W - M * 2) * 0.82, h: H * 0.4 } },
]
},
},
{
id: 'grid-9',
labelKey: 'journey.studio.tpl.grid9',
photoSlots: 9,
build: page => grid(page, 3, 3).map(frame => ({ kind: 'photo' as const, frame })),
},
{
id: 'mosaic',
labelKey: 'journey.studio.tpl.mosaic',
photoSlots: 5,
build: page => {
const W = page.pageWidth * 2
const H = page.pageHeight
const availW = W - M * 2 - G * 2
const big = availW * 0.5
const small = (availW - big) / 2
const rowH = (H - M * 2 - G) / 2
return [
{ kind: 'photo', frame: { x: M, y: M, w: big, h: rowH * 2 + G } },
{ kind: 'photo', frame: { x: M + big + G, y: M, w: small, h: rowH } },
{ kind: 'photo', frame: { x: M + big + G * 2 + small, y: M, w: small, h: rowH } },
{ kind: 'photo', frame: { x: M + big + G, y: M + rowH + G, w: small, h: rowH } },
{ kind: 'photo', frame: { x: M + big + G * 2 + small, y: M + rowH + G, w: small, h: rowH } },
]
},
},
{
id: 'band-quote',
labelKey: 'journey.studio.tpl.bandQuote',
photoSlots: 2,
build: page => {
// Two pictures with the words between them, set large. For the entry that
// said something worth reading twice.
const W = page.pageWidth
const H = page.pageHeight
const band = H * 0.34
const top = (H - band) / 2
return [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: W + page.bleed, h: top + page.bleed } },
{ kind: 'photo', frame: { x: W, y: -page.bleed, w: W + page.bleed, h: top + page.bleed } },
{ kind: 'heading', frame: { x: M * 2, y: top + band * 0.24, w: W * 2 - M * 4, h: 16 } },
{ kind: 'body', frame: { x: W * 0.5, y: top + band * 0.62, w: W, h: band * 0.3 } },
]
},
},
{
id: 'stagger-four',
labelKey: 'journey.studio.tpl.staggerFour',
photoSlots: 4,
build: page => {
// Alternate frames drop, which is what stops four squares in a row
// reading as a contact sheet.
const W = page.pageWidth * 2
const H = page.pageHeight
const cw = (W - M * 2 - G * 3) / 4
const ch = H * 0.62
return [0, 1, 2, 3].map(i => ({
kind: 'photo' as const,
frame: { x: M + i * (cw + G), y: M + (i % 2 ? H * 0.16 : 0), w: cw, h: ch },
}))
},
},
{
id: 'portrait-pair',
labelKey: 'journey.studio.tpl.portraitPair',
photoSlots: 2,
build: page => {
const W = page.pageWidth
const H = page.pageHeight
const pw = W - M * 2
const ph = H - M * 2 - 24
return [
{ kind: 'photo', frame: { x: M, y: M, w: pw, h: ph } },
{ kind: 'photo', frame: { x: W + M, y: M, w: pw, h: ph } },
{ kind: 'meta', frame: { x: M, y: H - M - 16, w: pw, h: 5 } },
{ kind: 'body', frame: { x: W + M, y: H - M - 16, w: pw, h: 12 } },
]
},
},
]
/**
* Layouts for a single page — the cover and the back.
*
* Kept apart from the spread layouts because a cover is a different problem: it
* is one page, the type is the subject rather than a caption to a photograph,
* and the arrangements that work are the ones that leave a picture room to be
* looked at while a title sits over it legibly. A spread layout applied here
* would put half its frames past the edge of the page.
*/
export const COVER_TEMPLATES: Template[] = [
{
id: 'cover-full',
labelKey: 'journey.studio.tpl.coverFull',
photoSlots: 1,
role: 'single',
build: page => [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: page.pageHeight + page.bleed * 2 } },
{ kind: 'heading', frame: { x: M, y: page.pageHeight - M - 46, w: page.pageWidth - M * 2, h: 30 } },
{ kind: 'meta', frame: { x: M, y: page.pageHeight - M - 12, w: page.pageWidth - M * 2, h: 8 } },
],
},
{
id: 'cover-band',
labelKey: 'journey.studio.tpl.coverBand',
photoSlots: 1,
role: 'single',
build: page => {
// The picture stops short of the foot, and the title sits on the paper
// rather than on the photograph — the safest cover there is, because it
// is legible whatever the photograph turns out to be.
const h = page.pageHeight * 0.68
return [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: h + page.bleed } },
{ kind: 'heading', frame: { x: M, y: h + 16, w: page.pageWidth - M * 2, h: 26 } },
{ kind: 'meta', frame: { x: M, y: h + 44, w: page.pageWidth - M * 2, h: 8 } },
]
},
},
{
id: 'cover-window',
labelKey: 'journey.studio.tpl.coverWindow',
photoSlots: 1,
role: 'single',
build: page => {
const inset = page.pageWidth * 0.13
return [
{ kind: 'photo', frame: { x: inset, y: inset, w: page.pageWidth - inset * 2, h: page.pageHeight - inset * 2.6 } },
{ kind: 'heading', frame: { x: inset, y: page.pageHeight - inset * 1.4, w: page.pageWidth - inset * 2, h: 20 } },
]
},
},
{
id: 'cover-quiet',
labelKey: 'journey.studio.tpl.coverQuiet',
photoSlots: 0,
role: 'single',
build: page => [
{ kind: 'heading', frame: { x: M, y: page.pageHeight * 0.42, w: page.pageWidth - M * 2, h: 28 } },
{ kind: 'meta', frame: { x: M, y: page.pageHeight * 0.42 + 32, w: page.pageWidth - M * 2, h: 8 } },
],
},
{
id: 'cover-half',
labelKey: 'journey.studio.tpl.coverHalf',
photoSlots: 2,
role: 'single',
build: page => {
const half = page.pageHeight * 0.5
return [
{ kind: 'photo', frame: { x: -page.bleed, y: -page.bleed, w: page.pageWidth + page.bleed * 2, h: half + page.bleed } },
{ kind: 'photo', frame: { x: -page.bleed, y: half, w: page.pageWidth + page.bleed * 2, h: page.pageHeight - half + page.bleed } },
{ kind: 'heading', frame: { x: M, y: half - 34, w: page.pageWidth - M * 2, h: 26 } },
]
},
},
]
const uid = (p: string) => `${p}-${Math.random().toString(36).slice(2, 9)}`
const HEADING = { size: 22, weight: 700 as const, leading: 1.1, tracking: -0.02, color: '#141414' }
const META = { size: 7.5, weight: 600 as const, tracking: 0.14, color: '#8a8578' }
const BODY = { size: 10, weight: 400 as const, leading: 1.6, color: '#2a2a2a' }
function textEl(frame: Frame, text: string, style: typeof HEADING | typeof META | typeof BODY, source: BookElement | null): BookElement {
const base = source && source.kind === 'text' ? source : null
return {
id: uid('t'),
kind: 'text',
frame,
rotation: 0,
opacity: 1,
locked: false,
text,
font: base?.font ?? 'sans',
italic: false,
align: base?.align ?? 'left',
tracking: 0,
binding: base?.binding ?? null,
overridden: base?.overridden ?? false,
...style,
} as BookElement
}
/**
* Pour the spread's existing content into a different arrangement.
*
* Order is kept, because order is the only thing that survives a layout change
* with its meaning intact: the first photo stays the first photo. Text keeps its
* binding to the journal entry, so a re-laid page still updates when the entry
* is edited.
*/
export function applyTemplate(spread: BookSpread, tpl: Template, page: BookPageSetup): BookSpread {
const slots = tpl.build(page)
// Placed content first, then whatever an earlier layout had to park: the
// pictures on the page keep their order, and the ones that were set aside
// queue up behind them.
const pool = [...spread.elements, ...(spread.parked ?? [])]
const photos = pool.filter(e => e.kind === 'photo')
const texts = pool.filter(e => e.kind === 'text')
/*
* Which text is the heading, which the story, which the date line.
*
* Size decides the heading first, and length only sorts what is left. The
* other way round — longest is the story, then look for a heading among the
* rest — reads a spread with three texts correctly and gets a cover with one
* exactly wrong: the single 30pt title becomes "the longest text", so it is
* the story, so a cover layout with a heading slot and no body slot has
* nowhere to put it and parks the title.
*/
const heading = [...texts]
.filter(t => t.kind === 'text' && t.size >= 14)
.sort((a, b) => (b.kind === 'text' ? b.size : 0) - (a.kind === 'text' ? a.size : 0))[0] ?? null
const rest = texts.filter(t => t !== heading)
const body = [...rest]
.sort((a, b) => (b.kind === 'text' ? b.text.length : 0) - (a.kind === 'text' ? a.text.length : 0))[0] ?? null
const meta = rest.find(t => t !== body) ?? null
let pi = 0
const out: BookElement[] = []
const usedText = new Set<BookElement>()
for (const slot of slots) {
if (slot.kind === 'photo') {
const src = photos[pi++]
out.push({
id: uid('p'),
kind: 'photo',
frame: slot.frame,
rotation: 0,
opacity: 1,
locked: false,
photoId: src && src.kind === 'photo' ? src.photoId : null,
fit: src && src.kind === 'photo' ? src.fit : 'cover',
focalX: src && src.kind === 'photo' ? src.focalX : 0.5,
focalY: src && src.kind === 'photo' ? src.focalY : 0.5,
radius: src && src.kind === 'photo' ? src.radius : 0,
filter: src && src.kind === 'photo' ? src.filter : 'none',
// The cut and the decoration travel with the picture. A layout change
// rearranges a page; it is not a reason for a photograph to come out of
// its Polaroid.
mask: src && src.kind === 'photo' ? src.mask : null,
frameStyle: src && src.kind === 'photo' ? src.frameStyle : 'none',
} as BookElement)
continue
}
const src = slot.kind === 'heading' ? heading : slot.kind === 'meta' ? meta : body
const value = src && src.kind === 'text' ? src.text : ''
if (!value) continue
if (src) usedText.add(src)
out.push(textEl(
slot.frame,
value,
slot.kind === 'heading' ? HEADING : slot.kind === 'meta' ? META : BODY,
src,
))
}
// Everything the arrangement could not take. An empty photo frame carries no
// picture, so it is not worth parking; a photo with an id and a text that was
// never placed are.
const parked = [
...photos.slice(pi).filter(e => e.kind === 'photo' && e.photoId != null),
...texts.filter(e => !usedText.has(e) && e.kind === 'text' && e.text.trim()),
].slice(0, 60)
return { ...spread, elements: out, parked }
}
@@ -0,0 +1,103 @@
import type { BookElement, JourneyStats } from '@trek/shared'
/**
* Keeping a travel element in step with the journey it came from.
*
* A travel element carries its own values — see the note in `book.schema.ts` —
* which is what lets the print renderer work without a network. The cost is
* that a journey edited afterwards leaves the page holding yesterday's numbers,
* and these two functions are how the editor offers to fix that without ever
* doing it behind the user's back.
*
* Pure, and separate from the inspector that calls them, so the rules can be
* tested against literal objects rather than through a rendered panel.
*/
/** The metrics a stats element can show, resolved from live figures. */
export function liveMetrics(stats: JourneyStats): Record<string, number> {
return {
distance: stats.distance,
days: stats.days,
steps: stats.steps,
photos: stats.photos,
countries: stats.countries.length,
places: stats.places,
furthest: stats.furthest,
}
}
/**
* Whether an element's stored values still match the journey.
*
* Compared, not tracked with a timestamp. A timestamp would say "the journey
* was edited", which is true every time anyone fixes a typo in a caption, and
* would mark a summary page stale over a change that did not move a single
* number on it.
*
* Only the figures the element actually shows are compared, for the same
* reason: a page showing distance and days does not go stale because a
* photograph was added.
*/
export function isStale(el: BookElement, stats: JourneyStats | null): boolean {
if (!stats) return false
if (el.kind === 'stats') {
const live = liveMetrics(stats)
return el.metrics.some(m => (el.values[m] ?? 0) !== live[m])
}
if (el.kind === 'countries') {
return el.codes.join(',') !== stats.countries.map(c => c.code).join(',')
}
if (el.kind === 'map') {
// The route is compared by shape rather than element-by-element: a
// four-hundred-point deep-equal on every render would cost more than it
// could ever catch, and a changed route almost always changes its length or
// one of its ends.
if (el.points.length !== stats.points.length) return true
if (!el.points.length) return false
const a = el.points[0]
const b = stats.points[0]
const y = el.points[el.points.length - 1]
const z = stats.points[stats.points.length - 1]
return a.lat !== b.lat || a.lng !== b.lng || y.lat !== z.lat || y.lng !== z.lng
}
return false
}
/**
* The patch that takes an element's values from the journey as it stands now.
*
* Returns null for an element that carries no journey figures, so a caller can
* offer refresh for the whole selection and let the ones that have nothing to
* refresh say so.
*/
export function refreshPatch(el: BookElement, stats: JourneyStats): Partial<BookElement> | null {
if (el.kind === 'stats') {
return { stale: false, values: liveMetrics(stats) } as Partial<BookElement>
}
if (el.kind === 'countries') {
const codes = stats.countries.map(c => c.code)
return {
stale: false,
codes,
// A country the element already had keeps the name it was given, which is
// the one in the book's language; a new country falls back to the API's
// English until someone re-places the element.
names: codes.map((code, i) => {
const had = el.codes.indexOf(code)
return had >= 0 ? (el.names[had] ?? code) : (stats.countries[i]?.name ?? code)
}),
} as Partial<BookElement>
}
if (el.kind === 'map') {
return {
stale: false,
countries: stats.countries.map(c => c.code),
points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label })),
} as Partial<BookElement>
}
return null
}
@@ -0,0 +1,160 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { addListener, joinBook, leaveBook, removeListener, sendBookCursor } from '../../api/websocket'
/**
* The other people in the book.
*
* ── Why pointers at all ──────────────────────────────────────────────────
*
* Two people editing the same document without seeing each other is how you get
* two people editing the same spread. A pointer with a name on it is the
* cheapest possible answer: it costs one small frame, it needs no agreement
* about anything, and it turns "why did that move" into "oh, that is Ada".
*
* ── Why millimetres ──────────────────────────────────────────────────────
*
* The two of you are at different zoom levels on different monitors, so a pixel
* is meaningless to the other one. The document is measured in millimetres and
* so is this: a pointer at 105 mm is at the gutter of a 210 mm page on any
* screen, at any zoom.
*
* ── Why it is throttled and interpolated ─────────────────────────────────
*
* A pointer moves continuously and a socket does not. Sending every mousemove
* would be sixty frames a second per editor for something the eye cannot use;
* sending ten and drawing the gaps looks the same and costs a sixth as much.
* The interpolation is in the component that draws them — this hook keeps the
* last position it was told about.
*/
/** How often this tab's own pointer goes out. Ten a second is plenty. */
const CURSOR_INTERVAL_MS = 90
export interface BookPeer {
socketId: number
userId: number
username: string
avatar?: string | null
}
export interface PeerCursor {
socketId: number
userId: number
username: string
spreadIndex: number
x: number
y: number
/** When it last moved, so a pointer that stopped can be faded out. */
at: number
}
interface Incoming {
type?: string
journeyId?: number
peers?: BookPeer[]
socketId?: number
userId?: number
spreadIndex?: number
x?: number | null
y?: number | null
}
export function useBookPresence(journeyId: number) {
const [peers, setPeers] = useState<BookPeer[]>([])
const [cursors, setCursors] = useState<PeerCursor[]>([])
/** Names, kept so a cursor frame does not have to carry one. */
const names = useRef(new Map<number, BookPeer>())
const lastSent = useRef(0)
const pending = useRef<{ spreadIndex: number; x: number | null; y: number | null } | null>(null)
const timer = useRef<number | null>(null)
useEffect(() => {
if (!Number.isFinite(journeyId)) return
const handler = (event: Record<string, unknown>) => {
const e = event as Incoming
if (e.journeyId !== journeyId) return
if (e.type === 'journey:book:peers' && Array.isArray(e.peers)) {
names.current = new Map(e.peers.map(p => [p.socketId, p]))
setPeers(e.peers)
// Somebody who left takes their pointer with them. Without this the
// arrow stays on the page, belonging to nobody.
setCursors(cs => cs.filter(c => names.current.has(c.socketId)))
return
}
if (e.type === 'journey:book:cursor' && typeof e.socketId === 'number') {
const socketId = e.socketId
if (e.x == null || e.y == null) {
setCursors(cs => cs.filter(c => c.socketId !== socketId))
return
}
const who = names.current.get(socketId)
const next: PeerCursor = {
socketId,
userId: e.userId ?? who?.userId ?? 0,
username: who?.username ?? '',
spreadIndex: e.spreadIndex ?? 0,
x: e.x,
y: e.y,
at: Date.now(),
}
setCursors(cs => {
const rest = cs.filter(c => c.socketId !== socketId)
return [...rest, next]
})
}
}
addListener(handler)
joinBook(journeyId)
return () => {
removeListener(handler)
leaveBook(journeyId)
setPeers([])
setCursors([])
names.current.clear()
}
}, [journeyId])
/**
* Note where this tab's pointer is.
*
* Called from a mousemove, so it is called constantly. One frame goes out
* every `CURSOR_INTERVAL_MS`, and the last position inside a window is the
* one that gets sent — a pointer's history is of no interest, only where it
* ended up.
*/
const moveCursor = useCallback((spreadIndex: number, x: number | null, y: number | null) => {
pending.current = { spreadIndex, x, y }
const flush = () => {
const p = pending.current
if (!p) return
pending.current = null
lastSent.current = Date.now()
sendBookCursor(journeyId, p.spreadIndex, p.x, p.y)
}
// Leaving the page goes immediately: an arrow that lingers where somebody
// is not is worse than no arrow.
if (x == null || y == null) {
if (timer.current != null) { window.clearTimeout(timer.current); timer.current = null }
flush()
return
}
if (timer.current != null) return
const wait = Math.max(0, CURSOR_INTERVAL_MS - (Date.now() - lastSent.current))
timer.current = window.setTimeout(() => { timer.current = null; flush() }, wait)
}, [journeyId])
useEffect(() => () => {
if (timer.current != null) window.clearTimeout(timer.current)
}, [])
return { peers, cursors, moveCursor }
}
@@ -0,0 +1,292 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import type { BookDocument, BookRecord } from '@trek/shared'
import { normalizeBookDocument } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { addListener, removeListener } from '../../api/websocket'
/**
* Saving the book.
*
* ── Why autosave rather than a save button ────────────────────────────────
*
* The document lived as long as the tab did, which made every session a race
* against a closed laptop. A save button would fix that and introduce a
* different failure: the one where you did press it, an hour ago, and cannot
* remember. Everyone on a journey can edit the same book, so an explicit save
* would also mean explicit merges.
*
* ── Why debounced rather than on every change ─────────────────────────────
*
* A drag is hundreds of frames and one edit. The store already models that —
* a gesture is one undo step — and this follows the same line: quiet for a
* moment, then write. The wait is short enough that closing the tab loses a
* sentence at most, and long enough that dragging a photograph across a spread
* is one request rather than four hundred.
*
* ── Why a version rather than a lock ─────────────────────────────────────
*
* See book-store.schema.ts. The short version: a save states which version it
* was made against, and one that has been overtaken is refused *with* the
* current record, so the client can say what happened instead of quietly
* discarding someone's afternoon.
*
* ── Why the other editors' saves arrive on their own ──────────────────────
*
* The server broadcasts `journey:book:saved` to everyone else on the journey,
* carrying the new version rather than the document — one integer instead of a
* few hundred kilobytes on every autosave. A client with nothing of its own
* outstanding fetches the new document and takes it: the book is shared, and
* sitting on a stale copy only means finding out at save time.
*
* A client that *does* have unsaved edits deliberately takes nothing. Its next
* save conflicts, and the conflict offers a choice, which is the whole point of
* the version. Quietly replacing someone's open document with somebody else's
* is exactly the failure this design exists to avoid.
*/
/** How long the document has to stay still before it is written. */
const AUTOSAVE_QUIET_MS = 1200
/** And how long it may go unwritten while someone keeps typing. */
const AUTOSAVE_MAX_MS = 15_000
export type SaveState =
| { status: 'idle' }
| { status: 'saving' }
| { status: 'saved'; at: number }
| { status: 'conflict'; current: BookRecord }
| { status: 'error' }
export function useBookStore(
journeyId: number,
/** Called when another editor's version is taken, with their document. */
onRemote?: (document: BookDocument) => void,
) {
const [record, setRecord] = useState<BookRecord | null>(null)
const [loaded, setLoaded] = useState(false)
const [state, setState] = useState<SaveState>({ status: 'idle' })
/** The version every save is made against, and what a conflict compares to. */
const version = useRef<number | null>(null)
const timer = useRef<number | null>(null)
const firstDirtyAt = useRef<number | null>(null)
const pending = useRef<{ document: BookDocument; title: string } | null>(null)
const inFlight = useRef(false)
/**
* The document the server and this client agree on, by identity.
*
* Loading one — at open, or when another editor's version arrives — puts that
* exact object into the editor store, so it comes straight back through
* queueSave on the next render. Without this it would be written back
* immediately, turning every open and every incoming change into a save and a
* version bump that changed nothing. Identity rather than a deep compare
* because queueSave runs on every commit, and stringifying a book per drag
* frame is not free.
*/
const synced = useRef<BookDocument | null>(null)
/** The document the editor currently holds, agreed on or not. */
const latest = useRef<BookDocument | null>(null)
/**
* Whether this client is holding work the server does not have.
*
* Broader than "a save is queued": after a conflict nothing is queued, and
* after keepMine nothing is queued either, yet in both cases the user is
* sitting on a document that is theirs. Comparing what the editor holds
* against what we last agreed on covers all three without a flag per case.
*/
const hasLocalWork = () =>
!!pending.current || inFlight.current || (!!latest.current && latest.current !== synced.current)
/**
* Set while a conflict is on screen, and nothing is written until it clears.
*
* Without it the editor keeps offering the same rejected document: every
* change queues a save, every save is refused, and the refusal re-renders the
* thing that queued it. The user sees a status that flickers and the server
* sees a client hammering it — over a question only the user can answer.
*/
const blocked = useRef(false)
/** Kept in a ref so the socket listener is not re-subscribed per render. */
const onRemoteRef = useRef(onRemote)
onRemoteRef.current = onRemote
useEffect(() => {
if (!Number.isFinite(journeyId)) return
let cancelled = false
journeyApi.getBook(journeyId)
.then(res => {
if (cancelled) return
/*
* Normalised here rather than by whoever renders it.
*
* A document written by an older build is missing whatever the contract
* has gained since, and the editor reads those fields without checking —
* `page.pageNumbers.show` on a book saved before page numbers existed is
* a white screen, not a missing feature.
*
* It has to happen at this seam and nowhere else: `synced` recognises the
* agreed document by identity, so a consumer that normalised on its own
* would hand back a different object and turn every open into a save.
*/
const book = res.book ? { ...res.book, document: normalizeBookDocument(res.book.document) } : null
setRecord(book)
version.current = book?.version ?? null
synced.current = book?.document ?? null
latest.current = book?.document ?? null
})
.catch(() => { /* A book that will not load is a book that gets created. */ })
.finally(() => { if (!cancelled) setLoaded(true) })
return () => { cancelled = true }
}, [journeyId])
const write = useCallback(async () => {
const next = pending.current
if (!next || inFlight.current || blocked.current) return
inFlight.current = true
pending.current = null
firstDirtyAt.current = null
setState({ status: 'saving' })
try {
const saved = await journeyApi.saveBook(journeyId, {
title: next.title,
document: next.document,
baseVersion: version.current ?? undefined,
})
setRecord(saved)
version.current = saved.version
synced.current = next.document
setState({ status: 'saved', at: Date.now() })
} catch (err) {
/*
* A 409 is not a failure, it is the other person.
*
* The current record comes back in the body precisely so this moment does
* not need a second request: the editor can offer to take their version
* or keep going from here, and either way it can say whose work it is.
*/
const res = (err as { response?: { status?: number; data?: { current?: BookRecord } } }).response
if (res?.status === 409 && res.data?.current) {
blocked.current = true
setState({ status: 'conflict', current: res.data.current })
} else {
setState({ status: 'error' })
}
} finally {
inFlight.current = false
// Anything that arrived while the request was open goes next, so a save
// during a save is queued rather than dropped.
if (pending.current) void write()
}
}, [journeyId])
/**
* Note that the document changed.
*
* Called on every commit. The write happens once the document has been still
* for a moment, or once it has been dirty long enough that waiting for a
* pause is no longer reasonable.
*/
const queueSave = useCallback((document: BookDocument, title: string) => {
latest.current = document
// The document we already agree on is not an edit — see `synced`.
if (document === synced.current) return
pending.current = { document, title }
if (firstDirtyAt.current == null) firstDirtyAt.current = Date.now()
if (timer.current != null) window.clearTimeout(timer.current)
const waited = Date.now() - firstDirtyAt.current
const delay = waited >= AUTOSAVE_MAX_MS ? 0 : AUTOSAVE_QUIET_MS
timer.current = window.setTimeout(() => { void write() }, delay)
}, [write])
/** Write immediately — for closing the editor, or a save the user asked for. */
const saveNow = useCallback((document?: BookDocument, title?: string) => {
if (document) pending.current = { document, title: title ?? '' }
if (timer.current != null) window.clearTimeout(timer.current)
return write()
}, [write])
/** Take the other version, discarding the local one. */
const acceptTheirs = useCallback((current: BookRecord) => {
const document = normalizeBookDocument(current.document)
blocked.current = false
setRecord({ ...current, document })
version.current = current.version
synced.current = document
latest.current = document
pending.current = null
setState({ status: 'idle' })
return document
}, [])
/**
* Keep going from here, on top of their version.
*
* Not a merge: it rebases the local document onto their version number so the
* next save lands. The two documents are not reconciled, and pretending
* otherwise would be worse than saying so — which is why the editor offers
* this next to the option of taking theirs.
*/
const keepMine = useCallback((current: BookRecord) => {
version.current = current.version
blocked.current = false
setState({ status: 'idle' })
}, [])
/**
* Someone else saved.
*
* Only acted on when this client has nothing outstanding — no queued edit, no
* request open, and no conflict already on screen. Anything else and the user
* has work of their own that a silent replacement would throw away.
*/
useEffect(() => {
if (!Number.isFinite(journeyId)) return
let cancelled = false
const handler = (event: Record<string, unknown>) => {
if (event.type !== 'journey:book:saved') return
if (event.journeyId !== journeyId) return
if (hasLocalWork()) return
// Already ours: the server excludes the saving socket, but a second tab
// on the same account is a different socket and the same person.
if (typeof event.version === 'number' && event.version === version.current) return
void journeyApi.getBook(journeyId)
.then(res => {
if (cancelled || !res.book) return
// Re-checked after the round trip: the user may have started editing
// while it was in the air, and their work outranks the refresh.
if (hasLocalWork()) return
// Normalised for the same reason the initial load is: a document
// written by an older build is missing whatever the contract has
// gained since.
const document = normalizeBookDocument(res.book.document)
setRecord({ ...res.book, document })
version.current = res.book.version
synced.current = document
latest.current = document
onRemoteRef.current?.(document)
})
.catch(() => { /* The next save will conflict and offer the choice. */ })
}
addListener(handler)
return () => {
cancelled = true
removeListener(handler)
}
}, [journeyId])
useEffect(() => () => {
if (timer.current != null) window.clearTimeout(timer.current)
}, [])
return { record, loaded, state, queueSave, saveNow, acceptTheirs, keepMine, version }
}
@@ -0,0 +1,357 @@
import { useCallback, useRef, useState } from 'react'
import type { BookElement, BookFrame, BookPageSetup, BookSpread } from '@trek/shared'
import { useStudioStore } from '../../store/studioStore'
/**
* Moving and resizing on the sheet.
*
* Hand-rolled on pointer events rather than a drag-and-drop library, because the
* libraries in this space move items between lists — they have no notion of a
* free frame, a resize handle, a rotation, or a magnetic edge. HTML5 drag and
* drop is worse still: it hands you a ghost image you cannot control and no
* position until the drop.
*
* Snapping is what separates an editor that feels precise from one that feels
* slippery. Candidate lines come from the page (edges, centres, safe area, the
* gutter) and from every other element on the spread (its edges and centres).
* The closest candidate within the threshold wins, the offset is applied, and
* the line is drawn so you can see *why* it stopped there.
*/
/** How close, in screen pixels, an edge has to come before it snaps. */
const SNAP_PX = 6
export interface Guide {
axis: 'x' | 'y'
/** Millimetres on the spread. */
at: number
}
export type HandleId = 'nw' | 'n' | 'ne' | 'e' | 'se' | 's' | 'sw' | 'w'
interface Gesture {
kind: 'move' | 'resize' | 'rotate'
handle?: HandleId
startX: number
startY: number
/** Frames as they were when the gesture started, by element id. */
base: Map<string, BookFrame>
/** Rotations as they were, for the turn. */
baseRotation?: Map<string, number>
/**
* Where the turn is measured from and where it started, both in screen
* pixels: the centre of the selection, and the angle the pointer stood at
* when it was grabbed. Turning by the *difference* is what lets the handle
* be picked up anywhere without the element jumping to meet the pointer.
*/
centreX?: number
centreY?: number
startAngle?: number
pointerId: number
}
/** Degrees from a centre to a point, measured the way CSS rotates. */
function angleAt(cx: number, cy: number, x: number, y: number): number {
return (Math.atan2(y - cy, x - cx) * 180) / Math.PI
}
/** How near an angle has to come to a step before it takes it. */
const ROTATE_STEP = 15
const ROTATE_SNAP_DEG = 4
export function snapTargets(spread: BookSpread, page: BookPageSetup, exclude: Set<string>) {
const single = spread.role !== 'inner'
const w = single ? page.pageWidth : page.pageWidth * 2
const h = page.pageHeight
const xs = [0, w / 2, w, page.safe, w - page.safe]
const ys = [0, h / 2, h, page.safe, h - page.safe]
/*
* A spread has four vertical safe lines, not two.
*
* The renderer draws the safe area per *page*, so a spread shows a dashed
* rule inset from each of its four vertical edges — including the two either
* side of the fold. Only the outer pair was a snap target, which is why
* dragging inwards caught the gutter itself and nothing else: the line you
* could see right before it was not something the pointer knew about.
*/
if (!single) {
xs.push(
page.pageWidth,
page.pageWidth - page.safe,
page.pageWidth + page.safe,
)
}
/*
* The centre of each page, and the quarters within it.
*
* On a spread `w / 2` is the fold, not the middle of anything you are
* composing on — a picture centred on its own page has no line to find. The
* quarters come along because half of a page is where a two-column split
* lands, which is the other measurement people reach for by eye.
*/
const pages = single ? 1 : 2
for (let i = 0; i < pages; i++) {
const left = i * page.pageWidth
xs.push(left + page.pageWidth * 0.25, left + page.pageWidth * 0.5, left + page.pageWidth * 0.75)
}
ys.push(h * 0.25, h * 0.75)
for (const el of spread.elements) {
if (exclude.has(el.id)) continue
xs.push(el.frame.x, el.frame.x + el.frame.w / 2, el.frame.x + el.frame.w)
ys.push(el.frame.y, el.frame.y + el.frame.h / 2, el.frame.y + el.frame.h)
}
return { xs, ys }
}
/** Best snap for a set of moving edges: returns the offset to apply, or 0. */
function bestSnap(edges: number[], targets: number[], tolMm: number): { delta: number; at: number } | null {
let best: { delta: number; at: number } | null = null
for (const edge of edges) {
for (const t of targets) {
const d = t - edge
if (Math.abs(d) > tolMm) continue
if (!best || Math.abs(d) < Math.abs(best.delta)) best = { delta: d, at: t }
}
}
return best
}
export function useSpreadInteraction(opts: {
spread: BookSpread | null
spreadIndex: number
page: BookPageSetup
/** Screen pixels per millimetre, i.e. zoom × 96/25.4. */
pxPerMm: number
}) {
const { spread, spreadIndex, page, pxPerMm } = opts
const selection = useStudioStore(s => s.selection)
const toggleSelect = useStudioStore(s => s.toggleSelect)
const select = useStudioStore(s => s.select)
const setFrame = useStudioStore(s => s.setFrame)
const setRotation = useStudioStore(s => s.setRotation)
const beginGesture = useStudioStore(s => s.beginGesture)
const endGesture = useStudioStore(s => s.endGesture)
const gesture = useRef<Gesture | null>(null)
const [guides, setGuides] = useState<Guide[]>([])
const [dragging, setDragging] = useState(false)
const tolMm = SNAP_PX / Math.max(pxPerMm, 0.0001)
const finish = useCallback((e: PointerEvent | React.PointerEvent) => {
if (!gesture.current) return
try {
(e.target as Element)?.releasePointerCapture?.(gesture.current.pointerId)
} catch {
// The element can be gone already — releasing a capture is best-effort.
}
gesture.current = null
setGuides([])
setDragging(false)
endGesture()
}, [endGesture])
const onPointerMove = useCallback((e: React.PointerEvent) => {
const g = gesture.current
if (!g || !spread) return
e.preventDefault()
const dxMm = (e.clientX - g.startX) / pxPerMm
const dyMm = (e.clientY - g.startY) / pxPerMm
const moving = new Set(g.base.keys())
const { xs, ys } = snapTargets(spread, page, moving)
const nextGuides: Guide[] = []
if (g.kind === 'rotate') {
/*
* Turned by the change in angle, not to the angle under the pointer.
*
* Taking the pointer's own angle would snap the element to wherever the
* handle was grabbed — a quarter turn the moment you touch it. The
* difference from where the gesture started is what makes it feel like
* holding a corner.
*/
const now = angleAt(g.centreX ?? 0, g.centreY ?? 0, e.clientX, e.clientY)
const delta = now - (g.startAngle ?? 0)
// Held to the nearest 15° unless shift is down, and always within a few
// degrees of straight: the angles people actually want are square, a
// slight tilt, and whatever the picture beside it is at.
for (const [id, base] of g.baseRotation ?? []) {
let next = base + delta
if (!e.shiftKey) {
const stepped = Math.round(next / ROTATE_STEP) * ROTATE_STEP
if (Math.abs(next - stepped) <= ROTATE_SNAP_DEG) next = stepped
}
next = ((next + 180) % 360 + 360) % 360 - 180
setRotation(spreadIndex, id, Math.round(next * 10) / 10)
}
setGuides([])
return
}
if (g.kind === 'move') {
// Snap on the union of all moving edges, so a multi-selection lines up as
// a block rather than each element fighting for its own line.
const movedX: number[] = []
const movedY: number[] = []
for (const f of g.base.values()) {
movedX.push(f.x + dxMm, f.x + f.w / 2 + dxMm, f.x + f.w + dxMm)
movedY.push(f.y + dyMm, f.y + f.h / 2 + dyMm, f.y + f.h + dyMm)
}
const sx = bestSnap(movedX, xs, tolMm)
const sy = bestSnap(movedY, ys, tolMm)
if (sx) nextGuides.push({ axis: 'x', at: sx.at })
if (sy) nextGuides.push({ axis: 'y', at: sy.at })
for (const [id, f] of g.base) {
setFrame(spreadIndex, id, {
x: f.x + dxMm + (sx?.delta ?? 0),
y: f.y + dyMm + (sy?.delta ?? 0),
w: f.w,
h: f.h,
})
}
} else if (g.handle) {
const h = g.handle
for (const [id, f] of g.base) {
let { x, y, w, hh } = { x: f.x, y: f.y, w: f.w, hh: f.h }
if (h.includes('w')) { x = f.x + dxMm; w = f.w - dxMm }
if (h.includes('e')) { w = f.w + dxMm }
if (h.includes('n')) { y = f.y + dyMm; hh = f.h - dyMm }
if (h.includes('s')) { hh = f.h + dyMm }
// Snap the edge actually being dragged, not the whole box.
if (h.includes('w')) {
const s = bestSnap([x], xs, tolMm)
if (s) { w += x - s.at; x = s.at; nextGuides.push({ axis: 'x', at: s.at }) }
}
if (h.includes('e')) {
const s = bestSnap([x + w], xs, tolMm)
if (s) { w = s.at - x; nextGuides.push({ axis: 'x', at: s.at }) }
}
if (h.includes('n')) {
const s = bestSnap([y], ys, tolMm)
if (s) { hh += y - s.at; y = s.at; nextGuides.push({ axis: 'y', at: s.at }) }
}
if (h.includes('s')) {
const s = bestSnap([y + hh], ys, tolMm)
if (s) { hh = s.at - y; nextGuides.push({ axis: 'y', at: s.at }) }
}
// A frame may not turn inside out. 4mm is about the smallest thing
// worth having on a page.
const MIN = 4
if (w < MIN) { if (h.includes('w')) x = f.x + f.w - MIN; w = MIN }
if (hh < MIN) { if (h.includes('n')) y = f.y + f.h - MIN; hh = MIN }
setFrame(spreadIndex, id, { x, y, w, h: hh })
}
}
setGuides(nextGuides)
}, [spread, page, pxPerMm, tolMm, setFrame, setRotation, spreadIndex])
const framesFor = useCallback((ids: string[]) => {
const map = new Map<string, BookFrame>()
if (!spread) return map
for (const el of spread.elements) if (ids.includes(el.id)) map.set(el.id, el.frame)
return map
}, [spread])
const startMove = useCallback((e: React.PointerEvent, el: BookElement) => {
e.stopPropagation()
const additive = e.shiftKey || e.metaKey || e.ctrlKey
const ids = selection.includes(el.id)
? (additive ? selection.filter(x => x !== el.id) : selection)
: (additive ? [...selection, el.id] : [el.id])
if (additive) { toggleSelect(el.id, true) } else if (!selection.includes(el.id)) { select([el.id]) }
/*
* Locked means "cannot be moved", not "cannot be reached".
*
* Returning before the selection was made was a dead end: the element could
* not be picked, so the inspector never showed it, so the unlock button was
* unreachable and the lock could not be undone by any means short of undo.
* Selection happens first and then the gesture stops here.
*/
if (el.locked) return
const targets = ids.length ? ids : [el.id]
;(e.target as Element).setPointerCapture?.(e.pointerId)
beginGesture()
gesture.current = {
kind: 'move',
startX: e.clientX,
startY: e.clientY,
base: framesFor(targets),
pointerId: e.pointerId,
}
setDragging(true)
}, [selection, toggleSelect, select, beginGesture, framesFor])
/**
* Take hold of the rotation handle.
*
* The centre comes from the selection's own box rather than from the handle,
* so a multi-selection turns as one object around the middle of the group —
* which is what a group of things being turned together looks like.
*/
const startRotate = useCallback((e: React.PointerEvent) => {
e.stopPropagation()
;(e.target as Element).setPointerCapture?.(e.pointerId)
if (!spread) return
const chosen = spread.elements.filter(el => selection.includes(el.id) && !el.locked)
if (chosen.length === 0) return
const x0 = Math.min(...chosen.map(el => el.frame.x))
const x1 = Math.max(...chosen.map(el => el.frame.x + el.frame.w))
const y0 = Math.min(...chosen.map(el => el.frame.y))
const y1 = Math.max(...chosen.map(el => el.frame.y + el.frame.h))
// The stage's own box, so the centre is in the same pixels the pointer is.
const stage = (e.currentTarget as Element).closest('.st-stage')
const rect = stage?.getBoundingClientRect()
const centreX = (rect?.left ?? 0) + ((x0 + x1) / 2) * pxPerMm
const centreY = (rect?.top ?? 0) + ((y0 + y1) / 2) * pxPerMm
beginGesture()
gesture.current = {
kind: 'rotate',
startX: e.clientX,
startY: e.clientY,
base: framesFor(selection),
baseRotation: new Map(chosen.map(el => [el.id, el.rotation])),
centreX,
centreY,
startAngle: angleAt(centreX, centreY, e.clientX, e.clientY),
pointerId: e.pointerId,
}
setDragging(true)
}, [spread, selection, pxPerMm, beginGesture, framesFor])
const startResize = useCallback((e: React.PointerEvent, handle: HandleId) => {
e.stopPropagation()
;(e.target as Element).setPointerCapture?.(e.pointerId)
beginGesture()
gesture.current = {
kind: 'resize',
handle,
startX: e.clientX,
startY: e.clientY,
base: framesFor(selection),
pointerId: e.pointerId,
}
setDragging(true)
}, [selection, beginGesture, framesFor])
return { guides, dragging, startMove, startResize, startRotate, onPointerMove, finish }
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, Download, EyeOff, Settings2 } from 'lucide-react'
import { ChevronLeft, MapPin, Grid3x3, Upload, MoreHorizontal, Play, Image, Camera, EyeOff, Settings2 } from 'lucide-react'
import JourneyMap from '../../../components/Journey/JourneyMapAuto'
import type { JourneyMapAutoHandle } from '../../../components/Journey/JourneyMapAuto'
import PhotoLightbox from '../../../components/Journey/PhotoLightbox'
@@ -30,7 +30,7 @@ import MJourneySettingsSheet from './MJourneySettingsSheet'
*/
export default function MJourneyDetail() {
const {
id, navigate, toast, t, locale,
id, navigate, toast, t,
current, loading,
canEditEntries, canEditJourney,
view, setView,
@@ -203,14 +203,13 @@ export default function MJourneyDetail() {
const gallery = current.gallery || []
const dark = document.documentElement.classList.contains('dark')
// The book export and the suggestions switch used to be desktop-only (#1848).
// They live in the header overflow sheet here, together with the settings
// entry, so the header keeps a single "more" affordance.
const exportBook = async () => {
setShowActionMenu(false)
const pdf = await import('../../../components/PDF/JourneyBookPDF')
await pdf.downloadJourneyBookPDF(current, { t, locale })
}
// The suggestions switch used to be desktop-only (#1848). It lives in the
// header overflow sheet here, together with the settings entry, so the header
// keeps a single "more" affordance.
//
// There is no book export here any more: the book is a Studio document now,
// and Studio is a desktop editor. A phone-sized PDF of a book laid out for
// print was the old export's answer to a question nobody was asking.
const toggleSkeletons = async (next: boolean) => {
setHideSkeletons(next)
try {
@@ -378,7 +377,6 @@ export default function MJourneyDetail() {
{/* Journey actions: book export, suggestions switch, settings */}
<MSheet open={showActionMenu} onClose={() => setShowActionMenu(false)} variant="bottom" ariaLabel={t('files.menu')}>
<div className="flex flex-col gap-1 p-[10px]">
<MListRow icon={Download} label={t('journey.pdf.saveAsPdf')} onClick={exportBook} />
<div className="flex items-center gap-[11px] px-[10px] py-[11px]">
<EyeOff size={16} strokeWidth={2} className="flex-none text-m-muted" />
<span className="min-w-0 flex-1 truncate text-[0.84375rem] font-semibold">{t('journey.skeletons.hide')}</span>
+26 -11
View File
@@ -8,9 +8,10 @@ import PhotoLightbox from '../components/Journey/PhotoLightbox'
import ContributorInviteDialog from '../components/Journey/ContributorInviteDialog'
import ConfirmDialog from '../components/shared/ConfirmDialog'
import EmptyState from '../components/shared/EmptyState'
import { Outlet } from 'react-router'
import {
ArrowLeft, MoreHorizontal, Download, List, Grid, MapPin,
Plus, ChevronUp, ChevronDown, Eye, EyeOff,
ArrowLeft, MoreHorizontal, List, Grid, MapPin,
Plus, ChevronUp, ChevronDown, Eye, EyeOff, BookOpen,
} from 'lucide-react'
import MobileMapTimeline from '../components/Journey/MobileMapTimeline'
import MobileEntryView from '../components/Journey/MobileEntryView'
@@ -35,6 +36,7 @@ function JourneyDetailPageDesktop() {
// scroll-synced map and the map/trip-date derivations live in the hook.
const {
id, navigate, toast, t, locale,
openStudio, prefetchStudio,
current, loading,
canEditEntries, canEditJourney, myRole,
view, setView, activeEntryId, setActiveEntryId, feedRef,
@@ -78,12 +80,9 @@ function JourneyDetailPageDesktop() {
const showMobileGallery = isMobile && view === 'gallery'
const isMobileChromeless = showMobileCombined || showMobileGallery
// Below 1024px the hero is gone, so its two actions have to live in the
// floating bar instead — they were unreachable there until #1848. Only one of
// the two hosts is mounted at a time, so both can carry the same labels.
const openBookPdf = () => {
import('../components/PDF/JourneyBookPDF').then(m => m.downloadJourneyBookPDF(current, { t, locale }))
}
// Below 1024px the hero is gone, so its actions have to live in the floating
// bar instead — they were unreachable there until #1848. Only one of the two
// hosts is mounted at a time, so both can carry the same labels.
const toggleSkeletons = async () => {
const next = !hideSkeletons
setHideSkeletons(next)
@@ -166,8 +165,13 @@ function JourneyDetailPageDesktop() {
</div>
<div className="flex items-center gap-1.5">
<button onClick={openBookPdf} aria-label={t('journey.pdf.saveAsPdf')} className={barButton}>
<Download size={16} />
<button
onClick={openStudio}
onMouseEnter={prefetchStudio}
aria-label={t('journey.studio.openAria')}
className={barButton}
>
<BookOpen size={16} />
</button>
<button
onClick={toggleSkeletons}
@@ -251,7 +255,14 @@ function JourneyDetailPageDesktop() {
</div>
</div>
<div className="flex items-center gap-1.5">
<button onClick={openBookPdf} aria-label={t('journey.pdf.saveAsPdf')} className="w-[34px] h-[34px] rounded-full bg-white/15 backdrop-blur flex items-center justify-center hover:bg-white/25"><Download size={14} /></button>
<button
onClick={openStudio}
onMouseEnter={prefetchStudio}
className="inline-flex h-[34px] items-center gap-1.5 px-3.5 rounded-full bg-white/15 backdrop-blur border border-white/15 text-[12px] font-semibold hover:bg-white/25"
>
<BookOpen size={14} />
{t('journey.studio.open')}
</button>
<div className="relative group">
<button
onClick={toggleSkeletons}
@@ -626,6 +637,10 @@ function JourneyDetailPageDesktop() {
onClose={() => setLightbox(null)}
/>
)}
{/* TREK Studio. It portals itself over the page, so this journey keeps
rendering underneath and shows through the panel's margin. */}
<Outlet />
</div>
)
}
@@ -10,7 +10,6 @@ import JourneyDetailPage from './JourneyDetailPage';
const mocks = vi.hoisted(() => ({
detail: {} as Record<string, unknown>,
captured: {} as Record<string, Record<string, unknown>>,
downloadPdf: vi.fn(),
}));
vi.mock('./journeyDetail/useJourneyDetail', () => ({
@@ -23,9 +22,6 @@ vi.mock('../mobile/screens/journey/MJourneyDetail', () => ({
default: () => <div data-testid="mobile-detail" />,
}));
vi.mock('../components/PDF/JourneyBookPDF', () => ({
downloadJourneyBookPDF: mocks.downloadPdf,
}));
vi.mock('../components/Journey/JourneyMapAuto', async () => {
const React = await import('react');
@@ -155,13 +151,14 @@ describe('JourneyDetailPage wiring', () => {
expect(hook.navigate).toHaveBeenCalledWith('/journey');
});
it('FE-JRN-DETWIRE-004: the download button lazy-loads the PDF book renderer with the app language', async () => {
const { hook } = setup();
fireEvent.click(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' }));
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
hook.current,
{ t: expect.any(Function), locale: 'en' },
));
/*
* The old book export is gone: Studio is the book now, and two exports of the
* same thing that disagree about what it looks like is worse than one.
*/
it('FE-JRN-DETWIRE-004: offers Studio where the PDF export used to be', () => {
setup();
expect(screen.queryByRole('button', { name: 'journey.pdf.saveAsPdf' })).not.toBeInTheDocument();
expect(screen.getByText('journey.studio.open')).toBeInTheDocument();
});
it('FE-JRN-DETWIRE-005: the eye toggle flips hide_skeletons and persists the preference', async () => {
@@ -427,8 +424,7 @@ describe('JourneyDetailPage wiring', () => {
it('FE-JRN-DETWIRE-025: a non-owner gets the mobile bar without the settings button', () => {
setup({ isMobile: true, canEditJourney: false });
expect(screen.queryByRole('button', { name: 'journey.settings.title' })).not.toBeInTheDocument();
// Export and the suggestions switch are not owner-only.
expect(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' })).toBeInTheDocument();
// The suggestions switch is not owner-only.
expect(screen.getByRole('button', { name: 'journey.skeletons.hide' })).toBeInTheDocument();
});
@@ -499,15 +495,11 @@ describe('JourneyDetailPage wiring', () => {
expect(screen.queryByLabelText('journey.detail.jumpToTop')).not.toBeInTheDocument();
});
// ── Book export + suggestions below 1024px (#1848) ────────────────────────
// ── Suggestions below 1024px (#1848) ──────────────────────────────────────
it('FE-JRN-DETWIRE-033: the mobile bar exports the book with the app language', async () => {
const { hook } = setup({ isMobile: true });
fireEvent.click(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' }));
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
hook.current,
{ t: expect.any(Function), locale: 'en' },
));
it('FE-JRN-DETWIRE-033: the narrow bar has no book export either', () => {
setup({ isMobile: true });
expect(screen.queryByRole('button', { name: 'journey.pdf.saveAsPdf' })).not.toBeInTheDocument();
});
it('FE-JRN-DETWIRE-034: the mobile bar toggle flips hide_skeletons and persists it', async () => {
@@ -526,9 +518,9 @@ describe('JourneyDetailPage wiring', () => {
expect(screen.queryByRole('button', { name: 'journey.skeletons.hide' })).not.toBeInTheDocument();
});
it('FE-JRN-DETWIRE-036: both actions stay reachable on the mobile gallery tab', () => {
it('FE-JRN-DETWIRE-036: the remaining actions stay reachable on the mobile gallery tab', () => {
setup({ isMobile: true, view: 'gallery' });
expect(screen.getByRole('button', { name: 'journey.pdf.saveAsPdf' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'journey.studio.openAria' })).toBeInTheDocument();
expect(screen.getByRole('button', { name: 'journey.skeletons.hide' })).toBeInTheDocument();
});
});
+14
View File
@@ -0,0 +1,14 @@
import StudioShell from '../components/Studio/StudioShell'
/**
* TREK Studio, the Journey book designer.
*
* A child route of /journey/:id rather than a page of its own: the journey stays
* mounted and shows through behind the panel, so Studio reads as something you
* opened on top of the journey instead of somewhere you navigated away to. The
* URL exists so a reload, the back button and a shared link all still work — the
* user never sees it.
*/
export default function JourneyStudioPage() {
return <StudioShell />
}
@@ -343,8 +343,37 @@ export function useJourneyDetail() {
return dates
}, [current?.trips])
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
const STUDIO_INSET = 16
/**
* Open TREK Studio.
*
* The button's own rect travels along in the navigation state so the panel can
* grow out of the button instead of appearing from nowhere. The origin is
* expressed inside the panel's box, which starts 16px in from the left and
* `--nav-h + 16px` down from the top (see studio.css).
*/
const openStudio = useCallback((e: React.MouseEvent<HTMLElement>) => {
const r = e.currentTarget.getBoundingClientRect()
navigate(`/journey/${id}/studio`, {
state: {
studioOrigin: {
x: Math.round(r.left + r.width / 2 - STUDIO_INSET),
y: Math.round(r.top + r.height / 2 - STUDIO_INSET),
},
},
})
}, [navigate, id])
/** Warm the Studio chunk on hover so the click is not spent downloading it. */
const prefetchStudio = useCallback(() => {
void import('../JourneyStudioPage')
}, [])
return {
id, navigate, toast, t, locale,
openStudio, prefetchStudio,
current, loading,
canEditEntries, canEditJourney, myRole,
view, setView, activeEntryId, setActiveEntryId, feedRef,
@@ -0,0 +1,621 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import { useLocation, useNavigate, useParams } from 'react-router'
import type { BookPageNumbers, JourneyStats } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { journeyApi } from '../../api/client'
import { useJourneyStore, type GalleryPhoto, type JourneyEntry, type JourneyPhoto } from '../../store/journeyStore'
import { useStudioStore } from '../../store/studioStore'
import { useBookStore } from '../../components/Studio/useBookStore'
import { useBookPresence } from '../../components/Studio/useBookPresence'
import { useTranslation } from '../../i18n'
import { useIsMobile } from '../../hooks/useIsMobile'
import { PAGE_PRESETS, clampPageSize, type PagePresetId } from '../../components/Studio/pagePresets'
import {
buildBook, distributeGallery, relayoutSpread,
type AutoEntry, type AutoInput, type AutoPhoto,
} from '../../components/Studio/autoLayout'
/** CSS defines 1in as 96px and 25.4mm, so this factor is exact, not a guess. */
const PX_PER_MM = 96 / 25.4
const ZOOM_STEPS = [0.1, 0.15, 0.25, 0.35, 0.5, 0.65, 0.8, 1, 1.25, 1.5, 2, 3]
const MIN_ZOOM = ZOOM_STEPS[0]
const MAX_ZOOM = ZOOM_STEPS[ZOOM_STEPS.length - 1]
const WORK_PADDING_PX = 72
/** Studio's margin to the window on all four sides — see `.st-root` in studio.css. */
export const STUDIO_INSET = 16
/**
* TREK Studio — the book designer's shell state.
*
* Studio is a child route of the journey, so the journey it edits is already in
* the store by the time this runs; it only asks for a load when someone lands on
* the URL directly and the parent has not finished yet.
*
* Two decisions are worth stating because everything else follows from them:
*
* 1. The sheet is measured in millimetres and rendered with CSS `mm`, and zoom
* is a single `transform: scale()` on top. Chromium maps mm onto PDF points
* with a fixed ratio, so the editor is not an approximation of the print —
* it is the same box model at a different scale.
* 2. Escape deselects, it does not close. In a dialog Escape means "go away"; in
* an editor that reflex would cost you the page you were working on. Only an
* Escape with nothing selected leaves.
*/
/**
* A journey's tracks, thinned to what a printed line can show.
*
* A three-week drive is hundreds of thousands of GPS fixes. At the size a map
* is printed, a point every fifteen metres and a point every kilometre are the
* same picture, and only one of them fits in a document that has to be saved,
* sent over a websocket and parsed on the other side.
*
* Thinned by taking every nth point rather than by simplifying the geometry:
* Douglas-Peucker would keep the shape better, and it is a hundred lines of
* code to gain something nobody can see at 60mm across. The ends of every
* segment are kept, because a track that stops short of where it ended reads
* as a bug.
*/
export function bookPath(tracks: { points: readonly unknown[][] }[]): [number, number][][] {
const SEGMENTS = 40
const PER_SEGMENT = 600
const out: [number, number][][] = []
for (const track of tracks.slice(0, SEGMENTS)) {
// Normalised on the way in: the contract types a pair as a tuple, and Zod
// infers that loosely enough that the pair has to be rebuilt to stay one.
const pts = track.points
.map(p => [Number(p[0]), Number(p[1])] as [number, number])
.filter(p => Number.isFinite(p[0]) && Number.isFinite(p[1]))
if (pts.length < 2) continue
if (pts.length <= PER_SEGMENT) { out.push(pts); continue }
const step = (pts.length - 1) / (PER_SEGMENT - 1)
const thinned: [number, number][] = []
for (let i = 0; i < PER_SEGMENT; i++) thinned.push(pts[Math.round(i * step)])
out.push(thinned)
}
return out
}
/**
* The page a Studio with no document yet reports.
*
* Parsed from the contract rather than written out, so a new field on the page
* setup cannot be missing here — and once, so it is not rebuilt per render.
*/
const EMPTY_PAGE = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210 })
export function useJourneyStudio() {
const { id } = useParams()
const navigate = useNavigate()
const location = useLocation()
const { t, locale } = useTranslation()
const isMobile = useIsMobile()
const { current, loading, loadJourney } = useJourneyStore()
const doc = useStudioStore(s => s.doc)
const loadDoc = useStudioStore(s => s.load)
const activeSpread = useStudioStore(s => s.activeSpread)
const setActiveSpread = useStudioStore(s => s.setActiveSpread)
const selection = useStudioStore(s => s.selection)
const select = useStudioStore(s => s.select)
const undo = useStudioStore(s => s.undo)
const redo = useStudioStore(s => s.redo)
const past = useStudioStore(s => s.past)
const future = useStudioStore(s => s.future)
const commit = useStudioStore(s => s.commit)
const journeyId = Number(id)
const backTo = `/journey/${id}`
// The rect of the button that opened Studio, handed over in the navigation
// state so the panel can grow out of it instead of appearing from nowhere.
const origin = (location.state as { studioOrigin?: { x: number; y: number } } | null)?.studioOrigin ?? null
const [closing, setClosing] = useState(false)
const [zoom, setZoom] = useState(0.4)
const [autoFit, setAutoFit] = useState(true)
const [stats, setStats] = useState<JourneyStats | null>(null)
/*
* Whether the figures have been *asked for* — success or failure both count.
*
* The auto layout uses them, so it has to wait for the answer rather than
* race it: laying the book out first would give a journey a summary spread
* or not depending on which promise resolved first, which is the kind of
* non-determinism nobody can reproduce when it goes wrong. Studio already
* shows its loading state until the document exists, so the wait is free.
*/
const [statsSettled, setStatsSettled] = useState(false)
/** The travelled way, thinned for the page. See `bookPath` for the thinning. */
const [path, setPath] = useState<[number, number][][]>([])
/*
* The stored book, if this journey has one.
*
* Studio used to lay a book out on every open, which meant the auto layout
* was not a starting point but the only point — close the tab and the
* afternoon went with it.
*/
const book = useBookStore(journeyId, loadDoc)
// Pulled out because close() needs it and the store object itself is new on
// every render — depending on that made the save effect fire per render.
const { queueSave, saveNow, loaded: bookLoaded } = book
/*
* Who else is in here, and where their pointers are.
*
* Deliberately unrelated to the saving above: presence is ephemeral and the
* document is not, and tangling them would mean a pointer could not move
* while a save was in flight.
*/
const presence = useBookPresence(journeyId)
const workRef = useRef<HTMLDivElement>(null)
const builtFor = useRef<number | null>(null)
/*
* What the book was laid out from, kept so it can be laid out again.
*
* Auto layout used to run exactly once, on open, which made it the one thing
* in Studio you could not ask for — a page that came out wrong had to be
* rebuilt by hand. Holding the input costs a ref and makes the button
* possible; rebuilding it from the journey on every click would be the same
* work plus a chance of the two disagreeing.
*/
const autoInput = useRef<AutoInput | null>(null)
const journey = current && current.id === journeyId ? current : null
useEffect(() => {
if (!Number.isFinite(journeyId)) return
if (!current || current.id !== journeyId) void loadJourney(journeyId)
}, [journeyId, current, loadJourney])
// Lay the book out once, from the journey. After that it is an ordinary
// document and this must not run again, or it would throw away the user's
// work every time the journey re-renders.
useEffect(() => {
if (!journey || !statsSettled || !book.loaded || builtFor.current === journey.id) return
builtFor.current = journey.id
/*
* A stored book wins over a fresh layout, always.
*
* The auto layout is what a journey with no book gets; running it over one
* that exists would throw away everything anyone had done, on open, with no
* warning. `autoInput` is still built either way, because the relayout
* button needs it.
*/
if (book.record) {
// Already normalised by useBookStore, and deliberately the same object it
// holds — see `synced` there, which is what keeps opening a book from
// counting as an edit.
loadDoc(book.record.document)
}
// A skeleton entry is a place pulled in from a trip that nobody has written
// about yet; without a title it has nothing to put on a page.
const entries: AutoEntry[] = (journey.entries || [])
.filter((e: JourneyEntry) => e.type !== 'skeleton' || !!e.title)
.map((e: JourneyEntry) => ({
id: e.id,
title: e.title ?? null,
story: e.story ?? null,
location: e.location_name ?? null,
date: e.entry_date ?? null,
photos: (e.photos || []).map((p: JourneyPhoto): AutoPhoto => ({
photoId: p.photo_id,
width: p.width ?? null,
height: p.height ?? null,
caption: p.caption ?? null,
})),
}))
const gallery: AutoPhoto[] = (journey.gallery || []).map((p: GalleryPhoto) => ({
photoId: p.photo_id,
width: p.width ?? null,
height: p.height ?? null,
caption: p.caption ?? null,
}))
const withPhotos = distributeGallery(entries, gallery)
/*
* Give every entry what the journey's figures know about its stop.
*
* The entry carries a place name; the stats carry what that name resolved
* to — coordinates and a country. Matched by date first and by name second,
* because a day usually has one stop and two stops on one day are told
* apart by what they are called.
*
* With these a page can print the flag, the country's outline and the
* coordinates, which is most of what makes a printed travel page look like
* one rather than like a document.
*/
const points = stats?.points ?? []
const dayCount = stats?.days ?? null
const first = stats?.start ? Date.parse(`${stats.start}T00:00:00`) : NaN
const placed = withPhotos.map(entry => {
const point = points.find(p => p.date && entry.date && p.date === entry.date)
?? points.find(p => p.label && entry.location && p.label === entry.location)
const at = entry.date ? Date.parse(`${entry.date}T00:00:00`) : NaN
const dayNumber = Number.isFinite(at) && Number.isFinite(first)
? Math.floor((at - first) / 86_400_000) + 1
: null
return {
...entry,
lat: point?.lat ?? null,
lng: point?.lng ?? null,
country: point?.country ?? null,
dayNumber: dayNumber && dayNumber > 0 ? dayNumber : null,
dayCount,
}
})
const preset = PAGE_PRESETS['square-210']
autoInput.current = {
locale,
title: journey.title || '',
subtitle: journey.subtitle ?? null,
coverPhotoId: gallery[0]?.photoId ?? null,
entries: placed,
page: {
preset: preset.id,
pageWidth: preset.pageWidthMm,
pageHeight: preset.pageHeightMm,
bleed: preset.bleedMm,
safe: preset.safeMm,
pageNumbers: bookPageSetupSchema.shape.pageNumbers.parse({}),
},
stats,
stationsLabel: t('journey.studio.stations'),
dayLabel: t('journey.studio.day'),
summaryLabel: t('journey.studio.summary'),
countriesLabel: t('journey.studio.countries'),
}
if (!book.record) loadDoc(buildBook(autoInput.current))
// `stats` is deliberately not a dependency: the book is laid out once, from
// the figures as they stood at that moment. Re-running on a later fetch
// would throw away the user's work — which is the same reason `builtFor`
// exists for the journey itself.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [journey, statsSettled, book.loaded, book.record, loadDoc, locale])
/*
* What the journey adds up to, for the travel elements.
*
* Fetched once and held here rather than inside the panel that offers them:
* the numbers are the same for every element on every spread, and a fetch per
* element would ask the server the same question twenty times while someone
* lays out a summary page.
*
* A failure is not an error state. The travel elements are one section of one
* panel; a book with photographs in it is still a book, and a red banner
* across the editor because a derived statistic could not be computed would
* be out of all proportion. The section simply says it has nothing yet.
*/
useEffect(() => {
if (!Number.isFinite(journeyId)) return
let cancelled = false
journeyApi.stats(journeyId)
.then(data => { if (!cancelled) setStats(data) })
.catch(() => { if (!cancelled) setStats(null) })
.finally(() => { if (!cancelled) setStatsSettled(true) })
return () => { cancelled = true }
}, [journeyId])
/*
* The roads the trip actually took.
*
* Separate from the statistics and deliberately not part of what the book
* waits for: a journey with no routed geometry is the common case, the map
* falls back to joining its stops, and holding the whole editor closed until
* a track request has answered would be a bad trade for a line.
*/
useEffect(() => {
if (!Number.isFinite(journeyId)) return
let cancelled = false
journeyApi.listTracks(journeyId)
.then(data => { if (!cancelled) setPath(bookPath(data.tracks)) })
.catch(() => { if (!cancelled) setPath([]) })
return () => { cancelled = true }
}, [journeyId])
/*
* The book has no name of its own.
*
* It carries the journey's, written into `doc.title` when the book is laid
* out — which is the name anyone would give it anyway. The bar used to offer
* a field for a second one; it was a box whose only real use was being left
* alone, and the space is better spent saying what this thing is.
*/
const preset = (doc?.page.preset ?? 'square-210') as PagePresetId
const page = doc?.page ?? EMPTY_PAGE
const spread = doc?.spreads[activeSpread] ?? null
const spreadWidthMm = spread && spread.role === 'inner' ? page.pageWidth * 2 : page.pageWidth
const setPreset = useCallback((next: PagePresetId) => {
const p = PAGE_PRESETS[next]
commit(d => ({
...d,
page: {
...d.page,
preset: p.id,
// Switching *to* custom keeps the page it is on: the free size is a way
// to adjust the format you have, not a reset to a default nobody chose.
pageWidth: next === 'custom' ? d.page.pageWidth : p.pageWidthMm,
pageHeight: next === 'custom' ? d.page.pageHeight : p.pageHeightMm,
},
}))
setAutoFit(true)
}, [commit])
/**
* A free trim size.
*
* Setting either dimension puts the document on the custom preset — a book
* that is 210 by 240 is not "square 21" with a typo in it, and leaving the
* preset alone would have the picker naming a format the page no longer is.
*/
const setPageSize = useCallback((axis: 'w' | 'h', value: number) => {
commit(d => ({
...d,
page: {
...d.page,
preset: 'custom' as const,
pageWidth: axis === 'w' ? clampPageSize(value) : d.page.pageWidth,
pageHeight: axis === 'h' ? clampPageSize(value) : d.page.pageHeight,
},
}))
setAutoFit(true)
}, [commit])
/**
* Lay out again — one spread, or the whole book.
*
* Both are ordinary commits, so both undo. That is the difference between an
* auto layout you can try and one you have to commit to: the first thing
* anyone does with a button like this is press it to see what happens, and
* the second is want their page back.
*
* The page setup is carried over rather than reset. Someone who chose A4 and
* turned page numbers on did not ask for that to be undone by a relayout.
*/
const relayoutBook = useCallback(() => {
const input = autoInput.current
if (!input) return
commit(d => ({
...buildBook({ ...input, page: d.page }),
title: d.title,
}))
setActiveSpread(0)
}, [commit, setActiveSpread])
const relayoutCurrentSpread = useCallback(() => {
const input = autoInput.current
if (!input) return
commit(d => {
const sp = d.spreads[activeSpread]
if (!sp) return d
const next = relayoutSpread(sp, { ...input, page: d.page })
if (!next) return d
return { ...d, spreads: d.spreads.map((x, i) => (i === activeSpread ? next : x)) }
})
select([])
}, [commit, activeSpread, select])
/** Whether the spread on screen came from an entry, and so can be redone. */
const canRelayoutSpread = !!(
autoInput.current
&& doc?.spreads[activeSpread]?.entryId != null
&& doc.spreads[activeSpread].role === 'inner'
)
/** Page numbers and anything else that belongs to the book rather than a page. */
const setPageNumbers = useCallback((patch: Partial<BookPageNumbers>) => {
commit(d => ({
...d,
page: { ...d.page, pageNumbers: { ...d.page.pageNumbers, ...patch } },
}))
}, [commit])
/*
* Save on change.
*
* Watching the document rather than wrapping every mutation: the store has a
* dozen ways to change a book and will grow more, and a save call at each of
* them is a save call somebody forgets to add. One effect on the value that
* matters cannot be forgotten.
*
* The document that was just loaded — at open, or when another editor's
* version arrives — is passed straight back here on the next render. The
* store recognises it by identity and does not write it back, so neither an
* open nor an incoming change shows up as an edit.
*/
useEffect(() => {
if (!doc || !bookLoaded || !journey) return
queueSave(doc, journey.title || '')
/*
* The callbacks, never the store object.
*
* useBookStore returns a fresh object every render, so depending on it ran
* this effect on every render — and after a conflict, where the local
* document is by definition not the saved one, that meant a save attempt
* per render: a 409, a re-render, another attempt, without end. The
* callbacks are memoised, so these dependencies hold still.
*/
}, [doc, queueSave, bookLoaded, journey])
/** Largest zoom at which the whole spread still fits the workbench. */
const fitZoom = useCallback(() => {
const el = workRef.current
if (!el) return 0.4
const availW = el.clientWidth - WORK_PADDING_PX * 2
const availH = el.clientHeight - WORK_PADDING_PX * 2
if (availW <= 0 || availH <= 0) return 0.4
const z = Math.min(availW / (spreadWidthMm * PX_PER_MM), availH / (page.pageHeight * PX_PER_MM))
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, z))
}, [spreadWidthMm, page.pageHeight])
const zoomToFit = useCallback(() => {
setAutoFit(true)
setZoom(fitZoom())
}, [fitZoom])
// Fit on mount and on resize, but only while the user has not taken over the
// zoom — otherwise a window resize would silently undo their choice.
useLayoutEffect(() => {
if (autoFit) setZoom(fitZoom())
}, [autoFit, fitZoom])
useEffect(() => {
const el = workRef.current
if (!el || typeof ResizeObserver === 'undefined') return
const ro = new ResizeObserver(() => { if (autoFit) setZoom(fitZoom()) })
ro.observe(el)
return () => ro.disconnect()
}, [autoFit, fitZoom])
const stepZoom = useCallback((dir: 1 | -1) => {
setAutoFit(false)
setZoom(prev => {
if (dir === 1) return ZOOM_STEPS.find(s => s > prev + 0.001) ?? MAX_ZOOM
return [...ZOOM_STEPS].reverse().find(s => s < prev - 0.001) ?? MIN_ZOOM
})
}, [])
const close = useCallback(() => {
// Whatever is still queued goes now: the debounce exists so a drag is one
// request, not so the last edit before closing is lost.
void saveNow()
setClosing(true)
// Mirrors the exit duration in studio.css.
window.setTimeout(() => navigate(backTo, { replace: true }), 180)
}, [navigate, backTo, saveNow])
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
const tag = (e.target as HTMLElement)?.tagName
const typing = tag === 'INPUT' || tag === 'TEXTAREA'
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === 'z') {
e.preventDefault()
if (e.shiftKey) redo(); else undo()
return
}
if (e.key !== 'Escape' || typing) return
// An editor's Escape clears the selection first; only a second one leaves.
if (selection.length) {
e.preventDefault()
select([])
return
}
e.preventDefault()
close()
}
window.addEventListener('keydown', onKey)
return () => window.removeEventListener('keydown', onKey)
}, [selection, select, close, undo, redo])
/** The journey's own material, for the content browser. */
const source = useMemo(() => ({
entries: (journey?.entries || [])
.filter((e: JourneyEntry) => !!(
e.title || e.story || e.location_name || e.mood || e.weather
|| e.pros_cons?.pros?.length || e.pros_cons?.cons?.length
))
.map((e: JourneyEntry) => ({
id: e.id,
title: e.title ?? null,
story: e.story ?? null,
location: e.location_name ?? null,
date: e.entry_date ?? null,
// What the entry recorded beyond its story: how the day felt, what the
// weather did, and what was worth and not worth it.
mood: e.mood ?? null,
weather: e.weather ?? null,
pros: e.pros_cons?.pros?.filter(Boolean) ?? [],
cons: e.pros_cons?.cons?.filter(Boolean) ?? [],
})),
photos: (journey?.gallery || []).map((p: GalleryPhoto) => ({
photoId: p.photo_id,
caption: p.caption ?? null,
})),
// Which entry a photo hangs on, so a search for a place finds its pictures
// even though a picture carries no words of its own.
photoEntries: (() => {
const map: Record<number, string> = {}
for (const e of (journey?.entries || []) as JourneyEntry[]) {
const words = [e.title, e.location_name].filter(Boolean).join(' ').toLowerCase()
for (const p of e.photos || []) map[p.photo_id] = words
}
return map
})(),
}), [journey])
const coverUrl = journey?.cover_image
? (journey.cover_image.startsWith('/uploads/') ? journey.cover_image : `/uploads/${journey.cover_image}`)
: null
return {
t,
locale,
isMobile,
journeyId,
journey,
loading: loading || !journey || !doc,
backTo,
origin,
closing,
close,
coverUrl,
source,
stats,
/** The travelled way, ready to freeze into a map element. */
path,
doc,
page,
preset,
setPreset,
setPageSize,
setPageNumbers,
relayoutBook,
relayoutCurrentSpread,
canRelayoutSpread,
/** Applying the other side of a conflict replaces the open document. */
loadDoc,
/** Everyone else with the book open, and their pointers. */
peers: presence.peers,
cursors: presence.cursors,
moveCursor: presence.moveCursor,
/** Autosave: its state, and the two ways out of a conflict. */
saveState: book.state,
saveNow: book.saveNow,
acceptTheirs: book.acceptTheirs,
keepMine: book.keepMine,
spread,
spreadWidthMm,
activeSpread,
setActiveSpread,
zoom,
zoomPercent: Math.round(zoom * 100),
stepZoom,
zoomToFit,
canZoomIn: zoom < MAX_ZOOM - 0.001,
canZoomOut: zoom > MIN_ZOOM + 0.001,
workRef,
undo,
redo,
canUndo: past.length > 0,
canRedo: future.length > 0,
pxPerMm: PX_PER_MM,
}
}
+333
View File
@@ -0,0 +1,333 @@
import { create } from 'zustand'
import type { BookDocument, BookElement, BookFrame, BookSpread } from '@trek/shared'
/**
* The book being edited.
*
* History is snapshot-based rather than patch-based, and deliberately so: a
* document is a few hundred kilobytes of plain JSON at most (the schema caps it
* at 150 spreads of 60 elements), and a snapshot stack that cannot possibly
* disagree with the document beats a patch stack that can. It is capped so a
* long session does not grow without limit.
*
* Granularity is the part that matters for how it *feels*: a drag is one undo
* step, not four hundred. Callers open a gesture before the first move and close
* it on release; only that pair touches the stack.
*/
const HISTORY_LIMIT = 60
interface StudioState {
doc: BookDocument | null
selection: string[]
activeSpread: number
past: BookDocument[]
future: BookDocument[]
/** The document as it was when the current gesture started. */
gestureBase: BookDocument | null
load: (doc: BookDocument) => void
setActiveSpread: (i: number) => void
select: (ids: string[]) => void
toggleSelect: (id: string, additive: boolean) => void
beginGesture: () => void
endGesture: () => void
/** A single change that is its own undo step. */
commit: (fn: (doc: BookDocument) => BookDocument) => void
/** A change inside an open gesture — does not touch the stack by itself. */
apply: (fn: (doc: BookDocument) => BookDocument) => void
updateElement: (spreadIndex: number, id: string, patch: Partial<BookElement>) => void
setFrame: (spreadIndex: number, id: string, frame: BookFrame) => void
/**
* Turn one element, in degrees.
*
* `apply`, not `commit`, for the same reason setFrame is: a drag is one undo
* step, and the gesture that drives this calls it on every pointer move.
*/
setRotation: (spreadIndex: number, id: string, rotation: number) => void
addElement: (spreadIndex: number, el: BookElement) => void
removeElements: (spreadIndex: number, ids: string[]) => void
duplicate: (spreadIndex: number, ids: string[]) => void
raise: (spreadIndex: number, id: string, to: 'front' | 'back' | 'up' | 'down') => void
/** Insert an empty spread after `index`, and select it. */
addSpread: (index: number) => void
/**
* Put a spread that already has contents into the book after `index`.
*
* Separate from addSpread because the thing being inserted comes from
* outside the document — an imported file — and arrives complete. Fresh ids
* are minted here rather than trusted from the file: two elements sharing an
* id would confuse selection and every lookup after it.
*/
insertSpread: (index: number, spread: BookSpread) => void
/** Copy a spread, contents and all, directly after it. */
duplicateSpread: (index: number) => void
removeSpread: (index: number) => void
/** Move a spread one place towards the front or the back of the book. */
moveSpread: (index: number, dir: -1 | 1) => void
/** Whether a spread may be moved, deleted or duplicated at all. */
canEditSpread: (index: number) => boolean
undo: () => void
redo: () => void
canUndo: () => boolean
canRedo: () => boolean
}
function replaceSpread(doc: BookDocument, index: number, fn: (s: BookSpread) => BookSpread): BookDocument {
const spreads = doc.spreads.slice()
if (!spreads[index]) return doc
spreads[index] = fn(spreads[index])
return { ...doc, spreads }
}
export const useStudioStore = create<StudioState>((set, get) => ({
doc: null,
selection: [],
activeSpread: 0,
past: [],
future: [],
gestureBase: null,
load: doc => set({ doc, selection: [], activeSpread: 0, past: [], future: [], gestureBase: null }),
setActiveSpread: i => set({ activeSpread: i, selection: [] }),
select: ids => set({ selection: ids }),
toggleSelect: (id, additive) => set(s => {
if (!additive) return { selection: [id] }
return { selection: s.selection.includes(id) ? s.selection.filter(x => x !== id) : [...s.selection, id] }
}),
beginGesture: () => set(s => ({ gestureBase: s.doc })),
endGesture: () => set(s => {
// Nothing actually changed — a click that missed, a drag of zero pixels.
if (!s.gestureBase || s.gestureBase === s.doc) return { gestureBase: null }
return {
past: [...s.past, s.gestureBase].slice(-HISTORY_LIMIT),
future: [],
gestureBase: null,
}
}),
commit: fn => set(s => {
if (!s.doc) return {}
const next = fn(s.doc)
if (next === s.doc) return {}
return { doc: next, past: [...s.past, s.doc].slice(-HISTORY_LIMIT), future: [] }
}),
apply: fn => set(s => (s.doc ? { doc: fn(s.doc) } : {})),
updateElement: (spreadIndex, id, patch) => get().apply(doc =>
replaceSpread(doc, spreadIndex, sp => ({
...sp,
elements: sp.elements.map(e => (e.id === id ? ({ ...e, ...patch } as BookElement) : e)),
}))),
setFrame: (spreadIndex, id, frame) => get().apply(doc =>
replaceSpread(doc, spreadIndex, sp => ({
...sp,
elements: sp.elements.map(e => (e.id === id ? { ...e, frame } : e)),
}))),
setRotation: (spreadIndex, id, rotation) => get().apply(doc =>
replaceSpread(doc, spreadIndex, sp => ({
...sp,
elements: sp.elements.map(e => (e.id === id ? { ...e, rotation } : e)),
}))),
addElement: (spreadIndex, el) => {
get().commit(doc =>
replaceSpread(doc, spreadIndex, sp => ({ ...sp, elements: [...sp.elements, el] })))
/*
* Selected on arrival.
*
* Everything you would want to do next is in the inspector, and the
* inspector shows the selection: placing a day counter and then hunting for
* it on the page to click it before its settings appear is a step nobody
* asked for. Duplicating already worked this way; placing did not.
*/
set({ selection: [el.id] })
},
removeElements: (spreadIndex, ids) => {
get().commit(doc => replaceSpread(doc, spreadIndex, sp => ({
...sp,
elements: sp.elements.filter(e => !ids.includes(e.id)),
})))
set({ selection: [] })
},
duplicate: (spreadIndex, ids) => {
// Offset the copy so it is visibly a second thing rather than an exact
// overlay you cannot tell apart from the original.
const OFFSET = 4
const made: string[] = []
get().commit(doc => replaceSpread(doc, spreadIndex, sp => {
const copies = sp.elements
.filter(e => ids.includes(e.id))
.map(e => {
const id = `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}`
made.push(id)
return { ...e, id, frame: { ...e.frame, x: e.frame.x + OFFSET, y: e.frame.y + OFFSET } }
})
return { ...sp, elements: [...sp.elements, ...copies] }
}))
if (made.length) set({ selection: made })
},
raise: (spreadIndex, id, to) => get().commit(doc =>
replaceSpread(doc, spreadIndex, sp => {
const i = sp.elements.findIndex(e => e.id === id)
if (i < 0) return sp
const els = sp.elements.slice()
const [el] = els.splice(i, 1)
const at = to === 'front' ? els.length
: to === 'back' ? 0
: to === 'up' ? Math.min(els.length, i + 1)
: Math.max(0, i - 1)
els.splice(at, 0, el)
return { ...sp, elements: els }
})),
/*
* ── Spread management ────────────────────────────────────────────────
*
* The cover and the back cover are fixed points: a book has exactly one of
* each, they are single pages rather than spreads, and the layouts panel
* already refuses to touch them. So everything here operates on the inner
* spreads between them, and `canEditSpread` is the one place that decides
* what counts as inner — the rail, the menu and the store all ask it rather
* than each testing `role` for themselves.
*/
canEditSpread: index => {
const sp = get().doc?.spreads[index]
return !!sp && sp.role === 'inner'
},
addSpread: index => {
const doc = get().doc
if (!doc) return
/*
* After the given spread, and inside the covers at both ends.
*
* The lower bound is not decoration: a book with no inner spreads yet
* reports its last inner one as -1, which asked for a page at 0 — in front
* of the cover, where it could not be moved back from either, because a
* move only ever swaps with another inner spread. The first page of an
* empty book is the one this catches.
*/
const cover = doc.spreads.findIndex(sp => sp.role === 'cover')
const back = doc.spreads.findIndex(sp => sp.role === 'back')
const first = cover === -1 ? 0 : cover + 1
const limit = back === -1 ? doc.spreads.length : back
const at = Math.min(Math.max(index + 1, first), limit)
get().commit(d => ({
...d,
spreads: [
...d.spreads.slice(0, at),
{
id: `sp-${Math.random().toString(36).slice(2, 9)}`,
role: 'inner' as const,
background: null,
elements: [],
parked: [],
entryId: null,
},
...d.spreads.slice(at),
],
}))
set({ activeSpread: at, selection: [] })
},
insertSpread: (index, spread) => {
const doc = get().doc
if (!doc) return
// Same bounds as addSpread: inside the covers at both ends.
const cover = doc.spreads.findIndex(sp => sp.role === 'cover')
const back = doc.spreads.findIndex(sp => sp.role === 'back')
const first = cover === -1 ? 0 : cover + 1
const limit = back === -1 ? doc.spreads.length : back
const at = Math.min(Math.max(index + 1, first), limit)
get().commit(d => ({
...d,
spreads: [
...d.spreads.slice(0, at),
{
...spread,
id: `sp-${Math.random().toString(36).slice(2, 9)}`,
role: 'inner' as const,
elements: spread.elements.map(e => ({ ...e, id: `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}` })),
},
...d.spreads.slice(at),
],
}))
set({ activeSpread: at, selection: [] })
},
duplicateSpread: index => {
const doc = get().doc
if (!doc || !get().canEditSpread(index)) return
const source = doc.spreads[index]
const at = index + 1
get().commit(d => ({
...d,
spreads: [
...d.spreads.slice(0, at),
{
...source,
id: `sp-${Math.random().toString(36).slice(2, 9)}`,
// Fresh ids throughout: two elements sharing one id would confuse
// selection and every lookup that follows it.
elements: source.elements.map(e => ({ ...e, id: `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}` })),
parked: source.parked.map(e => ({ ...e, id: `${e.kind[0]}-${Math.random().toString(36).slice(2, 9)}` })),
},
...d.spreads.slice(at),
],
}))
set({ activeSpread: at, selection: [] })
},
removeSpread: index => {
const doc = get().doc
if (!doc || !get().canEditSpread(index)) return
get().commit(d => ({ ...d, spreads: d.spreads.filter((_, i) => i !== index) }))
// Land on the spread that took its place, or the one before it if the
// deleted spread was the last inner one.
const next = get().doc?.spreads ?? []
set({ activeSpread: Math.min(index, Math.max(0, next.length - 1)), selection: [] })
},
moveSpread: (index, dir) => {
const doc = get().doc
if (!doc || !get().canEditSpread(index)) return
const target = index + dir
// Only ever swaps with another inner spread, so the cover and back cover
// keep their places without needing to be special-cased anywhere else.
if (!get().canEditSpread(target)) return
get().commit(d => {
const spreads = d.spreads.slice()
const [moved] = spreads.splice(index, 1)
spreads.splice(target, 0, moved)
return { ...d, spreads }
})
set({ activeSpread: target, selection: [] })
},
undo: () => set(s => {
const prev = s.past[s.past.length - 1]
if (!prev || !s.doc) return {}
return { doc: prev, past: s.past.slice(0, -1), future: [s.doc, ...s.future].slice(0, HISTORY_LIMIT), selection: [] }
}),
redo: () => set(s => {
const next = s.future[0]
if (!next || !s.doc) return {}
return { doc: next, future: s.future.slice(1), past: [...s.past, s.doc].slice(-HISTORY_LIMIT), selection: [] }
}),
canUndo: () => get().past.length > 0,
canRedo: () => get().future.length > 0,
}))
File diff suppressed because it is too large Load Diff
@@ -12,16 +12,12 @@ const mocks = vi.hoisted(() => ({
focusMarker: vi.fn(),
highlightMarker: vi.fn(),
captured: {} as Record<string, Record<string, unknown>>,
downloadPdf: vi.fn(),
}));
vi.mock('../../../../src/pages/journeyDetail/useJourneyDetail', () => ({
useJourneyDetail: () => mocks.detail,
}));
vi.mock('../../../../src/components/PDF/JourneyBookPDF', () => ({
downloadJourneyBookPDF: mocks.downloadPdf,
}));
vi.mock('../../../../src/components/Journey/JourneyMapAuto', async () => {
const React = await import('react');
@@ -566,18 +562,17 @@ describe('MJourneyDetail', () => {
await waitFor(() => expect(screen.queryByTestId('provider-picker')).not.toBeInTheDocument());
});
// ── Book export + suggestions on the phone (#1848) ────────────────────────
// ── Suggestions on the phone (#1848) ──────────────────────────────────────
it('FE-MOB-JDET-032: the overflow sheet exports the book in the app language', async () => {
const { hook } = setup();
/*
* No book export on a phone at all. The book is a Studio document now and
* Studio is a desktop editor — a phone-sized PDF of something laid out for
* print was the old export's answer to a question nobody was asking.
*/
it('FE-MOB-JDET-032: the overflow sheet offers no book export', () => {
setup();
fireEvent.click(screen.getByRole('button', { name: 'files.menu' }));
fireEvent.click(screen.getByText('journey.pdf.saveAsPdf'));
await waitFor(() => expect(mocks.downloadPdf).toHaveBeenCalledWith(
hook.current,
{ t: expect.any(Function), locale: 'en-US' },
));
await waitFor(() => expect(screen.queryByText('journey.pdf.saveAsPdf')).not.toBeInTheDocument());
expect(screen.queryByText('journey.pdf.saveAsPdf')).not.toBeInTheDocument();
});
it('FE-MOB-JDET-033: the suggestions switch flips hide_skeletons and persists it', async () => {
@@ -0,0 +1,225 @@
import { describe, it, expect } from 'vitest'
import type { BookDocument, BookElement, BookSpread } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { render } from '../../helpers/render'
import { BookSheetsView } from '../../../src/components/Studio/BookSheetsView'
/**
* The book, laid out for a press (#1973).
*
* These assertions are millimetres read back off the DOM, because millimetres
* are what the renderer maps onto the PDF. A sheet that is two millimetres out
* looks fine on screen and comes back from the printer with the picture cut in
* the wrong place.
*/
const page = (over: Record<string, unknown> = {}) =>
bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, ...over })
const photo = (id: string): BookElement => ({
id, kind: 'photo', frame: { x: 0, y: 0, w: 420, h: 210 },
rotation: 0, opacity: 1, locked: false,
photoId: 7, fit: 'cover', focalX: 0.5, focalY: 0.5,
radius: 0, filter: 'none', mask: null, frameStyle: 'none',
} as BookElement)
const spread = (role: BookSpread['role'], id: string, elements: BookElement[] = []): BookSpread => ({
id, role, background: '#ffffff', elements, parked: [], entryId: null,
})
function book(spreads: BookSpread[], over: Record<string, unknown> = {}): BookDocument {
return { version: 1, title: 'T', page: page(over), spreads } as BookDocument
}
/** The mm number off a style property, so the assertions read as measurements. */
function mm(el: HTMLElement, prop: 'width' | 'height' | 'left' | 'top'): number {
return parseFloat(el.style[prop])
}
function sheets(container: HTMLElement): HTMLElement[] {
return Array.from(container.querySelectorAll<HTMLElement>('.bx-sheet'))
}
/** The opening onto the spread — always a sheet's first child. */
const windowOf = (sheet: HTMLElement) => sheet.children[0] as HTMLElement
/** The spread behind it, positioned so the right leaf shows the right half. */
const canvasOf = (sheet: HTMLElement) => windowOf(sheet).children[0] as HTMLElement
describe('sheet geometry', () => {
it('sizes every sheet to the trim plus bleed when marks are off', () => {
const { container } = render(
<BookSheetsView doc={book([spread('cover', 'c')])} mode="pages" marks={false} />,
)
const [sheet] = sheets(container)
expect(mm(sheet, 'width')).toBe(216)
expect(mm(sheet, 'height')).toBe(216)
})
it('grows the sheet to make room for the marks', () => {
const { container } = render(
<BookSheetsView doc={book([spread('cover', 'c')])} mode="pages" marks />,
)
const [sheet] = sheets(container)
expect(mm(sheet, 'width')).toBe(224)
})
/*
* Eight hairlines, two per corner. Fewer means a corner without a mark, which
* is a corner the cutter has to guess at.
*/
it('draws two marks at each of the four corners', () => {
const { container } = render(
<BookSheetsView doc={book([spread('cover', 'c')])} mode="pages" marks />,
)
const [sheet] = sheets(container)
const hairlines = Array.from(sheet.children).filter(
c => (c as HTMLElement).style.background === 'rgb(0, 0, 0)',
)
expect(hairlines).toHaveLength(8)
})
it('draws none when they were not asked for', () => {
const { container } = render(
<BookSheetsView doc={book([spread('cover', 'c')])} mode="pages" marks={false} />,
)
const [sheet] = sheets(container)
const hairlines = Array.from(sheet.children).filter(
c => (c as HTMLElement).style.background === 'rgb(0, 0, 0)',
)
expect(hairlines).toHaveLength(0)
})
})
describe('cutting a spread', () => {
/*
* The one that decides whether a picture across the gutter survives printing:
* both leaves must be the same spread seen through openings exactly one page
* apart. Any other number and the halves do not meet.
*/
it('shifts the right-hand leaf by exactly one page width', () => {
const { container } = render(
<BookSheetsView doc={book([spread('inner', 'a', [photo('p')])])} mode="pages" marks={false} />,
)
const [left, right] = sheets(container)
// bleed - offset: 3 for the left leaf, 3 - 210 for the right.
expect(mm(canvasOf(left), 'left')).toBe(3)
expect(mm(canvasOf(right), 'left')).toBe(-207)
// Both windows hold the whole spread, not half of it.
expect(mm(canvasOf(left), 'width')).toBe(420)
expect(mm(canvasOf(right), 'width')).toBe(420)
})
it('keeps the whole spread on one sheet in spread mode', () => {
const { container } = render(
<BookSheetsView doc={book([spread('inner', 'a')])} mode="spreads" marks={false} />,
)
expect(sheets(container)).toHaveLength(1)
expect(mm(sheets(container)[0], 'width')).toBe(426)
})
/*
* A cover is one page wide and stays that way, even in a document whose other
* sheets are two. Sizing every sheet to the widest put the cover in the
* middle of a spread-sized page with white either side, which is not a cover.
*/
it('keeps a cover at its own size next to spreads', () => {
const { container } = render(
<BookSheetsView
doc={book([spread('cover', 'c'), spread('inner', 'a')])}
mode="spreads"
marks={false}
/>,
)
const [cover, inner] = sheets(container)
expect(mm(cover, 'width')).toBe(216)
expect(mm(inner, 'width')).toBe(426)
// Nothing is shifted to centre it — the window sits at the margin.
expect(mm(windowOf(cover), 'left')).toBe(0)
})
/* And says so, so the print CSS can give it a page box of its own. */
it('marks the one-page sheets for the named page rule', () => {
const { container } = render(
<BookSheetsView
doc={book([spread('cover', 'c'), spread('inner', 'a'), spread('back', 'b')])}
mode="spreads"
marks={false}
/>,
)
expect(sheets(container).map(s => s.classList.contains('is-single'))).toEqual([true, false, true])
})
it('marks every sheet single in page mode, where they all are', () => {
const { container } = render(
<BookSheetsView
doc={book([spread('cover', 'c'), spread('inner', 'a')])}
mode="pages"
marks={false}
/>,
)
expect(sheets(container).every(s => s.classList.contains('is-single'))).toBe(true)
})
})
describe('the bleed', () => {
/*
* The one that shows up as a white hairline along every edge of a finished
* book. On screen the page stops at the trim and anything past it is clipped;
* in print the part past the trim is exactly what gets cut away, so clipping
* it leaves nothing to cut into.
*/
it('lets the page run past the trim, and clips at the sheet instead', () => {
const { container } = render(
<BookSheetsView doc={book([spread('inner', 'a', [photo('p')])])} mode="pages" marks={false} />,
)
const [sheet] = sheets(container)
// The renderer does not clip.
const paper = canvasOf(sheet).children[0] as HTMLElement
expect(paper.style.overflow).toBe('visible')
// The window does, at trim plus a bleed on each side.
expect(windowOf(sheet).style.overflow).toBe('hidden')
expect(mm(windowOf(sheet), 'width')).toBe(216)
})
/* A coloured page cut with a white edge on three sides is the same defect. */
it('carries the page colour out into the bleed', () => {
const coloured = spread('inner', 'a')
coloured.background = '#123456'
const { container } = render(
<BookSheetsView doc={book([coloured])} mode="pages" marks={false} />,
)
expect(windowOf(sheets(container)[0]).style.background).toBe('rgb(18, 52, 86)')
})
})
describe('page breaks', () => {
it('breaks after every sheet but the last', () => {
const { container } = render(
<BookSheetsView
doc={book([spread('cover', 'c'), spread('inner', 'a'), spread('back', 'b')])}
mode="spreads"
marks={false}
/>,
)
const breaks = sheets(container).map(s => s.style.breakAfter)
// A break after the last sheet is the most common defect in a
// browser-printed PDF: one blank page at the end, every time.
expect(breaks).toEqual(['page', 'page', 'auto'])
})
})
describe('what the sheets are made of', () => {
/* One renderer. A second one for print is a second one to proofread. */
it('draws the document through the editor renderer, at full image size', () => {
const { container } = render(
<BookSheetsView doc={book([spread('inner', 'a', [photo('p')])])} mode="pages" marks={false} />,
)
const img = container.querySelector('img')
expect(img).not.toBeNull()
// `original`, not `thumbnail` — the print is the one place the big file is
// worth waiting for.
expect(img!.getAttribute('src')).toContain('/original')
})
})
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import { render } from '../../helpers/render'
import { PeerCursors } from '../../../src/components/Studio/PeerCursors'
import { peerColour } from '../../../src/components/Studio/peerColour'
import { PeerBadges } from '../../../src/components/Studio/PeerBadges'
import type { PeerCursor } from '../../../src/components/Studio/useBookPresence'
/**
* Drawing the other people (#1973).
*/
const cursor = (over: Partial<PeerCursor> = {}): PeerCursor => ({
socketId: 3, userId: 2, username: 'ada', spreadIndex: 0, x: 100, y: 50, at: 0, ...over,
})
const t = (k: string) => k
describe('the pointers', () => {
/* A pointer on page 40 has no business being drawn on page 2. */
it('draws only the ones on the spread being looked at', () => {
const { container } = render(
<PeerCursors
cursors={[cursor({ socketId: 1, spreadIndex: 0 }), cursor({ socketId: 2, spreadIndex: 7 })]}
spreadIndex={0}
zoom={1}
/>,
)
expect(container.querySelectorAll('.st-peer')).toHaveLength(1)
})
it('renders nothing at all when nobody is on this spread', () => {
const { container } = render(
<PeerCursors cursors={[cursor({ spreadIndex: 3 })]} spreadIndex={0} zoom={1} />,
)
expect(container.querySelector('.st-peer')).toBeNull()
})
it('places it in the spread millimetres it was given', () => {
const { container } = render(
<PeerCursors cursors={[cursor({ x: 140, y: 80 })]} spreadIndex={0} zoom={1} />,
)
const el = container.querySelector<HTMLElement>('.st-peer')!
expect(el.style.left).toBe('140mm')
expect(el.style.top).toBe('80mm')
})
/*
* The sheet is scaled, the arrow is not: one that grew with the zoom would be
* a postage stamp at 30% and a dinner plate at 400%.
*/
it('undoes the sheet scale so the arrow stays one size', () => {
const { container } = render(
<PeerCursors cursors={[cursor()]} spreadIndex={0} zoom={0.5} />,
)
expect(container.querySelector<HTMLElement>('.st-peer')!.style.transform).toBe('scale(2)')
})
it('says whose it is', () => {
const { container } = render(<PeerCursors cursors={[cursor()]} spreadIndex={0} zoom={1} />)
expect(container.querySelector('.st-peer-name')!.textContent).toBe('ada')
})
})
describe('the colours', () => {
/* Same colour in everybody's window, and tomorrow, without storing anything. */
it('are the same for the same person, every time', () => {
expect(peerColour(7)).toBe(peerColour(7))
})
it('differ between neighbouring ids, which is the common case', () => {
expect(peerColour(1)).not.toBe(peerColour(2))
})
it('hold up for an id that is negative or enormous', () => {
expect(peerColour(-3)).toMatch(/^#[0-9a-f]{6}$/)
expect(peerColour(9_999_999)).toMatch(/^#[0-9a-f]{6}$/)
})
})
describe('the badges', () => {
it('show nothing when nobody else is here', () => {
const { container } = render(<PeerBadges peers={[]} t={t} />)
expect(container.querySelector('.st-peers')).toBeNull()
})
/*
* By person, not by socket. The presence list is keyed by socket because two
* tabs are two pointers; showing them as two people says the room is busier
* than it is.
*/
it('count one person once, however many tabs they have open', () => {
const { container } = render(
<PeerBadges
peers={[
{ socketId: 1, userId: 2, username: 'ada' },
{ socketId: 2, userId: 2, username: 'ada' },
]}
t={t}
/>,
)
expect(container.querySelectorAll('.st-peer-dot')).toHaveLength(1)
})
it('fall back to an initial when somebody has no picture', () => {
const { container } = render(
<PeerBadges peers={[{ socketId: 1, userId: 2, username: 'ada' }]} t={t} />,
)
expect(container.querySelector('.st-peer-dot')!.textContent).toBe('A')
})
it('stop at four faces and count the rest', () => {
const peers = Array.from({ length: 7 }, (_, i) => ({
socketId: i, userId: i, username: `p${i}`,
}))
const { container } = render(<PeerBadges peers={peers} t={t} />)
expect(container.querySelectorAll('.st-peer-dot')).toHaveLength(4)
expect(container.querySelector('.st-peer-more')!.textContent).toBe('+3')
})
})
@@ -0,0 +1,101 @@
import { describe, it, expect, vi } from 'vitest'
import type { BookRecord } from '@trek/shared'
import { fireEvent, render, screen } from '../../helpers/render'
import { SaveIndicator } from '../../../src/components/Studio/SaveIndicator'
/**
* The save status (#1973).
*
* Autosave without a sign of it is a promise the user has to take on faith, and
* the moment they stop doing that is the moment they close the tab to check. So
* the interesting cases here are the two that are not "fine": an error the user
* can retry, and a conflict that has to offer a real choice rather than
* announcing that one exists.
*/
const t = (k: string) => k
const current = (over: Partial<BookRecord> = {}): BookRecord => ({
id: 3, journeyId: 9, title: 'Theirs', version: 6,
updatedAt: '2026-08-19 10:00:00', updatedBy: 2,
document: { version: 1, title: 'Theirs', page: {}, spreads: [] },
...over,
} as BookRecord)
function draw(state: Parameters<typeof SaveIndicator>[0]['state'], handlers = {}) {
const props = {
state, t,
onAcceptTheirs: vi.fn(), onKeepMine: vi.fn(), onRetry: vi.fn(),
...handlers,
}
render(<SaveIndicator {...props} />)
return props
}
describe('the quiet states', () => {
/*
* Idle shows nothing at all. A status that is always there is a status people
* learn to stop reading, which is exactly the wrong habit for the one time it
* says something else.
*/
it('renders nothing while idle', () => {
const { container } = render(
<SaveIndicator
state={{ status: 'idle' }} t={t}
onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()}
/>,
)
expect(container.textContent).toBe('')
})
it('says it is saving, and then that it saved', () => {
const { unmount } = render(
<SaveIndicator
state={{ status: 'saving' }} t={t}
onAcceptTheirs={vi.fn()} onKeepMine={vi.fn()} onRetry={vi.fn()}
/>,
)
expect(screen.getByText('journey.studio.saving')).toBeTruthy()
unmount()
draw({ status: 'saved', at: 0 })
expect(screen.getByText('journey.studio.saved')).toBeTruthy()
})
})
describe('an error', () => {
it('is a button, because the way out is to try again', () => {
const props = draw({ status: 'error' })
fireEvent.click(screen.getByText('journey.studio.saveFailed'))
expect(props.onRetry).toHaveBeenCalled()
})
})
describe('a conflict', () => {
/*
* There is no merge here, and pretending otherwise would be worse than saying
* so: one of these two documents is going to be the book. Both ways out are
* on screen, stated plainly.
*/
it('offers both ways out, and hands back the record it was given', () => {
const record = current()
const props = draw({ status: 'conflict', current: record })
expect(screen.getByText('journey.studio.saveConflict')).toBeTruthy()
fireEvent.click(screen.getByText('journey.studio.saveTakeTheirs'))
expect(props.onAcceptTheirs).toHaveBeenCalledWith(record)
fireEvent.click(screen.getByText('journey.studio.saveKeepMine'))
expect(props.onKeepMine).toHaveBeenCalledWith(record)
})
/*
* Announced, not just drawn. Losing an edit to someone else's save is the one
* thing here a screen-reader user must not have to notice by scanning.
*/
it('is announced rather than only drawn', () => {
draw({ status: 'conflict', current: current() })
expect(screen.getByRole('alert')).toBeTruthy()
})
})
@@ -0,0 +1,390 @@
import { describe, it, expect } from 'vitest'
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { render } from '../../helpers/render'
import { SpreadView } from '../../../src/components/Studio/SpreadView'
import { bookPageSetupSchema } from '@trek/shared'
/**
* The page renderer, with the elements added in #1973.
*
* `SpreadView` is the *only* renderer — headless Chromium runs this same
* component tree to make the PDF — so what these check is not "does the editor
* look right" but "would this print". The two questions that matter are whether
* an element draws at all, and whether it draws in millimetres.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
})
const frame = { x: 10, y: 20, w: 80, h: 60 }
const common = { frame, rotation: 0, opacity: 1, locked: false }
const typeset = { font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false }
function spread(elements: BookElement[]): BookSpread {
return { id: 's1', role: 'inner', background: null, elements, parked: [], entryId: null }
}
function draw(elements: BookElement[], props: { print?: boolean } = {}) {
return render(<SpreadView spread={spread(elements)} page={page} {...props} />)
}
/**
* The first element on the sheet.
*
* `SpreadView` renders the sheet as one absolutely-positioned div and the
* elements inside it, so a plain `div > div` selector finds the sheet — which
* is a div inside Testing Library's own container. This walks in one more.
*/
function firstElement(container: HTMLElement): HTMLElement | null {
const sheet = container.firstElementChild
return (sheet?.firstElementChild as HTMLElement) ?? null
}
const photo = (over: Record<string, unknown> = {}): BookElement => ({
...common, id: 'p1', kind: 'photo', photoId: 42, fit: 'cover',
focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', mask: null, frameStyle: 'none',
...over,
} as BookElement)
const shape = (over: Record<string, unknown> = {}): BookElement => ({
...common, id: 's1', kind: 'shape', shape: 'rect', fill: '#112233', gradient: 'none',
stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0,
...over,
} as BookElement)
describe('shapes', () => {
it('draws a rectangle as a box, which is what CSS is good at', () => {
const { container } = draw([shape()])
expect(container.querySelector('svg')).toBeNull()
expect(firstElement(container)!.style.width).toBe('80mm')
})
it('draws anything else as an SVG path', () => {
const { container } = draw([shape({ shape: 'heart' })])
const svg = container.querySelector('svg')
expect(svg).not.toBeNull()
expect(svg!.querySelector('path')?.getAttribute('d')).toMatch(/^M/)
})
/*
* The viewBox is in millimetres and the element is that many millimetres
* wide, so one user unit is one millimetre. That is what keeps a stroke the
* same width on every side of a non-square frame — the whole reason the path
* is scaled rather than the coordinate system.
*/
it('gives the SVG a viewBox in millimetres, matching its own size', () => {
const { container } = draw([shape({ shape: 'star-5' })])
const svg = container.querySelector('svg')!
expect(svg.getAttribute('viewBox')).toBe('0 0 80 60')
expect(svg.getAttribute('width')).toBe('80mm')
expect(svg.getAttribute('height')).toBe('60mm')
})
it('scales the path to the frame, so the drawing fills the box it was snapped to', () => {
const { container } = draw([shape({ shape: 'diamond' })])
const d = container.querySelector('path')!.getAttribute('d')!
const nums = (d.match(/-?\d*\.?\d+/g) ?? []).map(Number)
const xs = nums.filter((_, i) => i % 2 === 0)
const ys = nums.filter((_, i) => i % 2 === 1)
expect(Math.max(...xs)).toBeCloseTo(80, 1)
expect(Math.max(...ys)).toBeCloseTo(60, 1)
})
it('insets the path by half the stroke, keeping the outline inside the frame', () => {
const { container } = draw([shape({ shape: 'pentagon', stroke: '#000000', strokeWidth: 2 })])
const path = container.querySelector('path')!
expect(path.getAttribute('transform')).toBe('translate(1 1)')
expect(path.getAttribute('stroke-width')).toBe('2')
})
it('turns a dashed stroke into a dash pattern proportional to its width', () => {
const { container } = draw([shape({ shape: 'hexagon', stroke: '#000000', strokeWidth: 1, strokeStyle: 'dashed' })])
expect(container.querySelector('path')!.getAttribute('stroke-dasharray')).toBe('3 2')
})
it('gives a gear the even-odd rule so its middle is a hole', () => {
const { container } = draw([shape({ shape: 'gear', fill: '#000000' })])
expect(container.querySelector('path')!.getAttribute('fill-rule')).toBe('evenodd')
})
it('leaves a heart alone, so its subpaths do not knock holes in each other', () => {
const { container } = draw([shape({ shape: 'heart', fill: '#000000' })])
expect(container.querySelector('path')!.getAttribute('fill-rule')).toBeNull()
})
})
describe('photo frames', () => {
it('draws a plain photo without a clip path', () => {
const { container } = draw([photo()])
expect(container.querySelector('clipPath')).toBeNull()
expect(container.querySelector('img')).not.toBeNull()
})
it('cuts a masked photo with an objectBoundingBox clip, so it follows a resize', () => {
const { container } = draw([photo({ mask: 'heart' })])
const clip = container.querySelector('clipPath')!
expect(clip.getAttribute('clipPathUnits')).toBe('objectBoundingBox')
// 0..1 coordinates, not 0..100 — that is what objectBoundingBox means.
const nums = (clip.querySelector('path')!.getAttribute('d')!.match(/\d*\.?\d+/g) ?? []).map(Number)
expect(Math.max(...nums)).toBeLessThanOrEqual(1.06)
})
it('treats a rect mask as no mask at all', () => {
const { container } = draw([photo({ mask: 'rect' })])
expect(container.querySelector('clipPath')).toBeNull()
})
it('gives a Polaroid a white body and a chin thicker than its sides', () => {
const { container } = draw([photo({ frameStyle: 'polaroid' })])
const outer = firstElement(container)!
expect(outer.style.background).toContain('rgb(255, 255, 255)')
const inner = outer.querySelector('div') as HTMLElement
// 5.5% of the short side at the sides, 17% at the bottom.
expect(inner.style.top).toBe('3.3mm')
expect(inner.style.bottom).toBe('10.2mm')
})
it('gives film a dark body and sprocket holes', () => {
const { container } = draw([photo({ frameStyle: 'film' })])
const outer = firstElement(container)!
expect(outer.style.background).toContain('rgb(20, 20, 20)')
// The picture, plus the two sprocket strips.
expect(outer.querySelectorAll('div').length).toBeGreaterThan(2)
})
/*
* Two strips, each centred on its corner. Both halves matter: the panel
* preview draws two as well, and a preview that disagrees with the result
* makes the second strip look like a bug.
*/
it('tapes both top corners, each strip centred on its corner', () => {
const { container } = draw([photo({ frameStyle: 'tape' })])
const strips = [...firstElement(container)!.querySelectorAll('div')]
.filter(d => (d as HTMLElement).style.transform.includes('rotate'))
expect(strips).toHaveLength(2)
const [left, right] = strips as HTMLElement[]
// 26% of the short side wide, 7.5% tall; half of each is the offset.
expect(left.style.left).toBe('-7.8mm')
expect(left.style.top).toBe('-2.25mm')
expect(right.style.right).toBe('-7.8mm')
// Mirrored, so the pair reads as symmetric rather than as two of the same.
expect(left.style.transform).toBe('rotate(-45deg)')
expect(right.style.transform).toBe('rotate(45deg)')
})
it('applies a filter to the image rather than to the frame around it', () => {
const { container } = draw([photo({ filter: 'bw' })])
expect((container.querySelector('img') as HTMLElement).style.filter).toContain('grayscale')
})
/*
* An empty frame is an instruction to the person laying out the book. Printed,
* it would be a hatched rectangle on a page somebody paid to have bound.
*/
it('shows an empty frame while editing and prints nothing at all', () => {
const { container: editing } = draw([photo({ photoId: null })], {})
expect(firstElement(editing)).not.toBeNull()
const { container: printing } = draw([photo({ photoId: null })], { print: true })
expect(printing.querySelector('img')).toBeNull()
expect(firstElement(printing)).toBeNull()
})
})
describe('travel elements', () => {
const map = (over: Record<string, unknown> = {}): BookElement => ({
...common, ...typeset, id: 'm1', kind: 'map', style: 'minimal',
showLand: true, showRoute: true, showPins: true, showLabels: false,
countries: ['IS'],
points: [
{ lat: 64.14, lng: -21.94, label: 'Reykjavík' },
{ lat: 65.68, lng: -18.12, label: 'Akureyri' },
],
...over,
} as BookElement)
it('draws the route as a polyline and its stops as pins', () => {
const { container } = draw([map()])
expect(container.querySelector('polyline')).not.toBeNull()
expect(container.querySelectorAll('circle')).toHaveLength(2)
})
it('draws the country outlines under the route', () => {
const { container } = draw([map()])
// One path for Iceland; the route itself is a polyline, not a path.
expect(container.querySelectorAll('path').length).toBeGreaterThan(0)
})
it('leaves out the layers that are switched off', () => {
const { container } = draw([map({ showRoute: false, showPins: false, showLand: false })])
expect(container.querySelector('polyline')).toBeNull()
expect(container.querySelectorAll('circle')).toHaveLength(0)
})
it('labels the stops only when asked', () => {
expect(draw([map()]).container.querySelectorAll('text')).toHaveLength(0)
const { container } = draw([map({ showLabels: true })])
expect(container.textContent).toContain('Reykjavík')
})
it('survives a map with no stops and no countries rather than dividing by zero', () => {
const { container } = draw([map({ points: [], countries: [] })])
const svg = container.querySelector('svg')!
expect(svg.getAttribute('viewBox')).toBe('0 0 80 60')
expect(svg.innerHTML).not.toContain('NaN')
})
it('survives a single stop, where the bounds have no extent', () => {
const { container } = draw([map({ points: [{ lat: 64.14, lng: -21.94, label: 'one' }] })])
expect(container.querySelector('svg')!.innerHTML).not.toContain('NaN')
})
it('draws the figures with the values the document holds', () => {
const stats = {
...common, ...typeset, id: 'st1', kind: 'stats',
metrics: ['distance', 'days'], layout: 'grid', showIcons: true, units: 'metric',
values: { distance: 1_189_000, days: 14 },
} as unknown as BookElement
const { container } = draw([stats])
expect(container.textContent).toContain('1,189')
expect(container.textContent).toContain('14')
})
it('converts distance to miles without touching the stored metres', () => {
const stats = {
...common, ...typeset, id: 'st1', kind: 'stats',
metrics: ['distance'], layout: 'row', showIcons: false, units: 'imperial',
values: { distance: 1_609_344 },
} as unknown as BookElement
expect(draw([stats]).container.textContent).toContain('1,000')
})
it('names the countries and draws their silhouettes', () => {
const countries = {
...common, ...typeset, id: 'c1', kind: 'countries',
codes: ['IS', 'NO'], names: ['Iceland', 'Norway'],
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
} as unknown as BookElement
const { container } = draw([countries])
expect(container.textContent).toContain('Iceland')
expect(container.textContent).toContain('Norway')
expect(container.querySelectorAll('svg').length).toBe(2)
})
/*
* The bundled boundaries do not carry every dependency and territory as its
* own feature, so some codes have no silhouette. The name still has to print
* — a country page that silently drops a country would be worse than one
* without a drawing next to it.
*/
it('still names a country whose silhouette is not bundled', () => {
const countries = {
...common, ...typeset, id: 'c1', kind: 'countries',
codes: ['FO'], names: ['Faroe Islands'],
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
} as unknown as BookElement
const { container } = draw([countries])
expect(container.textContent).toContain('Faroe Islands')
expect(container.querySelectorAll('svg')).toHaveLength(0)
})
it('leaves out the outlines when they are switched off', () => {
const countries = {
...common, ...typeset, id: 'c1', kind: 'countries',
codes: ['IS'], names: ['Iceland'],
layout: 'list', showOutline: false, showFlag: false, showName: true, align: 'center',
} as unknown as BookElement
expect(draw([countries]).container.querySelectorAll('svg')).toHaveLength(0)
})
it('draws a badge with its figure and its caption', () => {
const badge = {
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'date', text: '13', sub: 'APRIL', code: null, style: 'stacked',
} as unknown as BookElement
const { container } = draw([badge])
expect(container.textContent).toContain('13')
expect(container.textContent).toContain('APRIL')
})
/*
* Drawn, not typed. A regional-indicator pair renders as the two letters on
* Windows, which is where most readers are — see the note in flags.ts.
*/
/*
* ── What colour a mark's words are ────────────────────────────────────
*
* A chip is a filled capsule, so its words answer to the fill rather than to
* the page. Ink on ink is the failure this prevents, and it is not a subtle
* one: a black day counter on a black chip is an empty capsule.
*/
it('sets a chip on a dark fill in paper, not in ink', () => {
const badge = {
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
accent: '#111111', color: '#111111', autoColor: true,
} as unknown as BookElement
const { container } = draw([badge])
const html = container.innerHTML
expect(html).toContain('rgb(255, 255, 255)')
})
it('sets a chip on a pale fill in ink', () => {
const badge = {
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
accent: '#f4efe6', color: '#ffffff', autoColor: true,
} as unknown as BookElement
expect(draw([badge]).container.innerHTML).toContain('rgb(28, 27, 25)')
})
/* Automatic is a good default and a bad cage: a chosen colour wins outright. */
it('uses the chosen colour once automatic is off, fill or no fill', () => {
const badge = {
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
accent: '#111111', color: '#c81e4a', autoColor: false,
} as unknown as BookElement
// The badge's own words, not the page behind it — the paper is white here,
// so asking the whole tree would pass whatever the mark did.
const words = draw([badge]).getByText('DAY 1')
expect(words.style.color).toBe('rgb(200, 30, 74)')
})
it('draws a flag badge as geometry rather than as an emoji', () => {
const badge = {
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'flag', text: '', sub: 'Iceland', code: 'IS', style: 'plain',
} as unknown as BookElement
const { container } = draw([badge])
expect(container.textContent).not.toContain('\u{1F1EE}\u{1F1F8}')
// Iceland is a Nordic cross: a field, an upright, a crossbar and two inner
// bars, plus the hairline that keeps a white edge off a white page.
expect(container.querySelectorAll('svg rect')).toHaveLength(6)
expect(container.querySelector('svg')!.getAttribute('viewBox')).toBe('0 0 30 20')
})
it('falls back to the outline for a country whose flag it cannot draw exactly', () => {
const badge = {
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'flag', text: '', sub: 'United Kingdom', code: 'GB', style: 'plain',
} as unknown as BookElement
const { container } = draw([badge])
// The silhouette, not a construction: one path, no bands.
expect(container.querySelectorAll('svg rect')).toHaveLength(0)
expect(container.querySelectorAll('svg path')).toHaveLength(1)
})
it('positions every travel element in millimetres, like everything else on the page', () => {
const badge = {
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip',
} as unknown as BookElement
const el = firstElement(draw([badge]).container)!
expect(el.style.left).toBe('10mm')
expect(el.style.top).toBe('20mm')
expect(el.style.width).toBe('80mm')
})
})
@@ -0,0 +1,143 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import type { BookDocument, BookSpread } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { fireEvent, render, screen } from '../../helpers/render'
const printed = vi.hoisted(() => vi.fn())
vi.mock('../../../src/components/Studio/printSheets', () => ({ printSheets: printed }))
import { StudioExport } from '../../../src/components/Studio/StudioExport'
/**
* The export dialog (#1973).
*
* Two questions and a button. What is worth pinning is what the button hands
* over: the sheet size the `@page` rule is built from, and markup that actually
* contains the book — a print view assembled from an empty stage is a stack of
* blank pages, and it looks fine right up until it is printed.
*/
const spread = (role: BookSpread['role'], id: string): BookSpread => ({
id, role, background: '#ffffff', elements: [], parked: [], entryId: null,
})
const doc = (spreads: BookSpread[] = [spread('cover', 'c'), spread('inner', 'a')]): BookDocument => ({
version: 1,
title: 'Iceland',
page: bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3 }),
spreads,
} as BookDocument)
const t = (key: string) => key
function open(over: Partial<Parameters<typeof StudioExport>[0]> = {}) {
const onClose = vi.fn()
render(<StudioExport doc={doc()} title="Iceland" t={t} onClose={onClose} {...over} />)
return { onClose }
}
beforeEach(() => { printed.mockReset() })
afterEach(() => { vi.useRealTimers() })
describe('the choices', () => {
it('starts on single pages, which is what a printer takes', () => {
open()
expect(screen.getByText('journey.studio.exportPages').closest('button')!.className)
.toContain('is-on')
})
it('starts with crop marks on', () => {
open()
expect(screen.getByText('journey.studio.exportMarks').closest('button')!.getAttribute('aria-pressed'))
.toBe('true')
})
it('switches to spreads when asked', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportSpreads'))
expect(screen.getByText('journey.studio.exportSpreads').closest('button')!.className)
.toContain('is-on')
expect(screen.getByText('journey.studio.exportPages').closest('button')!.className)
.not.toContain('is-on')
})
it('turns the marks off again', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportMarks'))
expect(screen.getByText('journey.studio.exportMarks').closest('button')!.getAttribute('aria-pressed'))
.toBe('false')
})
})
describe('handing over', () => {
/*
* The sheet size is the `@page` rule. Get it wrong and the printer scales the
* whole book to fit whatever it thought the paper was, which is the one defect
* that survives every proofread because it looks correct on screen.
*/
it('passes the sheet size the marks and bleed add up to', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportOpen'))
expect(printed).toHaveBeenCalledTimes(1)
const call = printed.mock.calls[0][0]
// 210 trim + 2 × (3 bleed + 4 marks).
expect(call.sheetWidth).toBe(224)
expect(call.sheetHeight).toBe(224)
})
it('drops the room for marks when they are off', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportMarks'))
fireEvent.click(screen.getByText('journey.studio.exportOpen'))
expect(printed.mock.calls[0][0].sheetWidth).toBe(216)
})
it('sizes the sheet to the spread when spreads were chosen', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportSpreads'))
fireEvent.click(screen.getByText('journey.studio.exportOpen'))
expect(printed.mock.calls[0][0].sheetWidth).toBe(420 + (3 + 4) * 2)
})
/* An empty stage prints as blank pages, and only says so on paper. */
it('hands over markup with the book in it', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportOpen'))
const { html } = printed.mock.calls[0][0]
expect(html).toContain('bx-sheet')
// Cover, then both leaves of the inner spread.
expect(html.match(/bx-sheet/g)).toHaveLength(3)
})
it('carries the book title into the print view', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportOpen'))
expect(printed.mock.calls[0][0].title).toBe('Iceland')
})
it('closes itself once the print view is up', () => {
const { onClose } = open()
fireEvent.click(screen.getByText('journey.studio.exportOpen'))
expect(onClose).toHaveBeenCalled()
})
/* Two clicks used to mean two print views, the second hidden behind the first. */
it('opens one print view, not one per render', () => {
open()
fireEvent.click(screen.getByText('journey.studio.exportOpen'))
expect(printed).toHaveBeenCalledTimes(1)
})
})
describe('leaving', () => {
it('closes without printing anything', () => {
const { onClose } = open()
fireEvent.click(screen.getByText('common.cancel'))
expect(onClose).toHaveBeenCalled()
expect(printed).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,131 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import type { BookElement } from '@trek/shared'
import { render } from '../../helpers/render'
import { TravelPreview } from '../../../src/components/Studio/TravelPreview'
/**
* The panel tiles (#1973).
*
* The point of these is that they are the real element at a smaller scale, not
* an icon standing in for one — four map styles are four maps, and a generic
* glyph cannot tell you which is which. So what is checked here is that the
* element genuinely renders, that its proportions survive, and that the tile
* takes its size from the element rather than from a number someone guessed.
*/
/*
* The tile measures itself, and jsdom reports every element as zero-sized. This
* gives the measured span a width the moment it is observed, which is what the
* browser does on the first frame — see useElementSize.test.ts for the same
* pattern applied to the hook itself.
*/
const OBSERVED_W = 190
class SizingResizeObserver {
constructor(private cb: ResizeObserverCallback) {}
observe(el: Element) {
Object.defineProperty(el, 'offsetWidth', { value: OBSERVED_W, configurable: true })
Object.defineProperty(el, 'offsetHeight', { value: 60, configurable: true })
this.cb([], this as unknown as ResizeObserver)
}
unobserve() {}
disconnect() {}
}
beforeEach(() => {
Object.defineProperty(HTMLElement.prototype, 'offsetWidth', {
get() { return OBSERVED_W },
configurable: true,
})
vi.stubGlobal('ResizeObserver', SizingResizeObserver)
})
afterEach(() => {
vi.unstubAllGlobals()
})
const typeset = { font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false }
const common = { rotation: 0, opacity: 1, locked: false }
const statsEl = (w: number, h: number): BookElement => ({
...common, ...typeset, id: 'st1', kind: 'stats',
frame: { x: 40, y: 90, w, h },
metrics: ['distance', 'days'], layout: 'grid', showIcons: true, units: 'metric',
values: { distance: 1_189_000, days: 14 },
} as unknown as BookElement)
const tileHeight = (container: HTMLElement) =>
parseFloat((container.firstElementChild as HTMLElement).style.height)
describe('TravelPreview', () => {
it('draws the element itself, with its real figures', () => {
const { container } = render(<TravelPreview el={statsEl(200, 60)} />)
expect(container.textContent).toContain('1,189')
expect(container.textContent).toContain('14')
})
it('takes its height from the element, so a wide panel gets a shallow tile', () => {
const wide = render(<TravelPreview el={statsEl(200, 50)} />)
const tall = render(<TravelPreview el={statsEl(80, 120)} />)
expect(tileHeight(wide.container)).toBeLessThan(tileHeight(tall.container))
})
it('clamps a very flat element rather than drawing a hairline', () => {
const { container } = render(<TravelPreview el={statsEl(400, 8)} minHeight={30} />)
expect(tileHeight(container)).toBe(30)
})
it('clamps a very tall element rather than running down the panel', () => {
const { container } = render(<TravelPreview el={statsEl(40, 400)} maxHeight={80} />)
expect(tileHeight(container)).toBe(80)
})
/*
* The tile is the frame. An element sitting at x=40 on the spread must draw
* at the tile's own origin, or half of it would be outside the preview.
*/
it('draws at the origin, ignoring where the element sits on the spread', () => {
const { container } = render(<TravelPreview el={statsEl(200, 60)} />)
const drawn = container.querySelector('.st-travel-preview > span > div') as HTMLElement
expect(drawn.style.left).toBe('0mm')
expect(drawn.style.top).toBe('0mm')
})
it('scales rather than squashes — one factor, applied to both axes', () => {
const { container } = render(<TravelPreview el={statsEl(200, 60)} />)
const scaler = container.querySelector('.st-travel-preview > span') as HTMLElement
const m = /scale\(([\d.]+)\)/.exec(scaler.style.transform)
expect(m).not.toBeNull()
expect(Number(m![1])).toBeGreaterThan(0)
expect(Number(m![1])).toBeLessThan(1)
})
/*
* The clipped right edge that started this: the drawn element must never be
* wider than the tile it was measured against.
*/
it('never draws wider than the width it measured', () => {
const { container } = render(<TravelPreview el={statsEl(400, 60)} />)
const scaler = container.querySelector('.st-travel-preview > span') as HTMLElement
const scale = Number(/scale\(([\d.]+)\)/.exec(scaler.style.transform)![1])
const drawnPx = 400 * (96 / 25.4) * scale
expect(drawnPx).toBeLessThanOrEqual(OBSERVED_W + 0.5)
expect(parseFloat(scaler.style.left)).toBeGreaterThanOrEqual(0)
})
it('renders a map tile without a broken coordinate', () => {
const map = {
...common, ...typeset, id: 'mp1', kind: 'map',
frame: { x: 0, y: 0, w: 150, h: 120 },
style: 'paper', showLand: true, showRoute: true, showPins: true, showLabels: false,
countries: ['IS'],
points: [
{ lat: 64.14, lng: -21.94, label: 'a' },
{ lat: 65.68, lng: -18.12, label: 'b' },
],
} as unknown as BookElement
const { container } = render(<TravelPreview el={map} />)
expect(container.querySelector('polyline')).not.toBeNull()
expect(container.querySelector('svg')!.innerHTML).not.toContain('NaN')
})
})
@@ -0,0 +1,174 @@
import { describe, it, expect } from 'vitest'
import type { BookElement } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { applyTemplate, templateFit, type TemplateEntry } from '../../../src/components/Studio/applyTemplate'
import type { SpreadTemplate } from '../../../src/components/Studio/bookTemplates.data'
/**
* Laying an entry onto a template somebody drew (#1973).
*
* The contract between the person drawing a template and the code using it is
* a division: what the entry can answer for gets filled, everything else is the
* design and is copied as drawn. These cases are that division, and the sizing
* that lets a template drawn on one trim size land on another.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210 })
const ctx = { page, locale: 'en', stats: null, dayLabel: 'DAY' }
/** Fractions of a page, the way the templates store them. */
const el = (over: Record<string, unknown>): BookElement => ({
id: 't', rotation: 0, opacity: 1, locked: false,
frame: { x: 0.1, y: 0.1, w: 0.5, h: 0.25 },
...over,
} as BookElement)
const template = (elements: BookElement[]): SpreadTemplate => ({
id: 'ref', background: '#faf8f4', elements,
})
const entry = (over: Partial<TemplateEntry> = {}): TemplateEntry => ({
id: 7, title: 'A day', story: 'What happened.', location: 'Berlin', date: '2026-07-08',
photos: [], lat: 52.52, lng: 13.4, country: 'DE', dayNumber: 3, dayCount: 10,
...over,
})
const titleEl = el({ kind: 'text', text: 'TITLE', size: 0.1, binding: { source: 'entry.title' } })
const storyEl = el({ kind: 'text', text: 'Lorem', size: 0.05, binding: { source: 'entry.story' } })
const frameEl = el({ kind: 'photo', photoId: null, fit: 'cover', focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none', mask: null, frameStyle: 'none' })
const panelEl = el({ kind: 'shape', shape: 'rect', fill: '#11224f', gradient: 'none', stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0 })
describe('sizing', () => {
/* Drawn as fractions so a template built on a square lands on an A4 too. */
it('multiplies the frame back up to the page it is laid on', () => {
const spread = applyTemplate(template([panelEl]), entry(), ctx)
expect(spread.elements[0].frame).toEqual({ x: 21, y: 21, w: 105, h: 52.5 })
})
it('scales to a different trim size without being redrawn', () => {
const a4 = bookPageSetupSchema.parse({ preset: 'a4-landscape', pageWidth: 297, pageHeight: 210 })
const spread = applyTemplate(template([panelEl]), entry(), { ...ctx, page: a4 })
expect(spread.elements[0].frame.x).toBe(29.7)
expect(spread.elements[0].frame.w).toBe(148.5)
})
it('takes type size off the page height, so it scales with the sheet', () => {
const spread = applyTemplate(template([titleEl]), entry(), ctx)
expect((spread.elements[0] as { size: number }).size).toBe(21)
})
})
describe('what gets filled', () => {
it('puts the entry title where the template asked for one', () => {
const spread = applyTemplate(template([titleEl]), entry(), ctx)
expect((spread.elements[0] as { text: string }).text).toBe('A day')
})
it('puts the story in', () => {
const spread = applyTemplate(template([storyEl]), entry(), ctx)
expect((spread.elements[0] as { text: string }).text).toBe('What happened.')
})
it('takes the photographs in order', () => {
const photos = [
{ photoId: 11, width: null, height: null },
{ photoId: 22, width: null, height: null },
]
const spread = applyTemplate(template([frameEl, frameEl]), entry({ photos }), ctx)
expect(spread.elements.map(e => (e as { photoId: number | null }).photoId)).toEqual([11, 22])
})
it('leaves a frame empty when the entry runs out of pictures', () => {
const spread = applyTemplate(
template([frameEl, frameEl]),
entry({ photos: [{ photoId: 11, width: null, height: null }] }),
ctx,
)
expect((spread.elements[1] as { photoId: number | null }).photoId).toBeNull()
})
it('fills the day chip from the stop', () => {
const chip = el({ kind: 'badge', variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip' })
const spread = applyTemplate(template([chip]), entry(), ctx)
expect((spread.elements[0] as { text: string }).text).toBe('DAY 3')
})
it('fills the coordinates', () => {
const mark = el({ kind: 'badge', variant: 'coords', text: '', sub: '', code: null, style: 'plain' })
const spread = applyTemplate(template([mark]), entry(), ctx)
expect((spread.elements[0] as { text: string }).text).toContain('52°')
expect((spread.elements[0] as { text: string }).text).toContain('13°')
})
/*
* A mark the entry cannot answer keeps what it was drawn with. "DAY 1" on an
* entry with no date reads as a placeholder, which is what it is.
*/
it('leaves a mark alone when the entry has nothing to put in it', () => {
const chip = el({ kind: 'badge', variant: 'day', text: 'DAY 1', sub: '', code: null, style: 'chip' })
const spread = applyTemplate(template([chip]), entry({ dayNumber: null }), ctx)
expect((spread.elements[0] as { text: string }).text).toBe('DAY 1')
})
it('belongs to the entry it was filled from', () => {
expect(applyTemplate(template([titleEl]), entry(), ctx).entryId).toBe(7)
})
it('keeps the paper colour the template was drawn on', () => {
expect(applyTemplate(template([panelEl]), entry(), ctx).background).toBe('#faf8f4')
})
})
describe('what is left alone', () => {
/* The design is the design: a panel stays where it was put. */
it('copies a decorative shape as drawn, colour and all', () => {
const spread = applyTemplate(template([panelEl]), entry(), ctx)
const shape = spread.elements[0] as { fill: string; kind: string }
expect(shape.kind).toBe('shape')
expect(shape.fill).toBe('#11224f')
})
it('gives every element a fresh id, so two spreads never share one', () => {
const a = applyTemplate(template([panelEl]), entry(), ctx)
const b = applyTemplate(template([panelEl]), entry(), ctx)
expect(a.elements[0].id).not.toBe(b.elements[0].id)
expect(a.id).not.toBe(b.id)
})
})
describe('choosing a template', () => {
/*
* A template is one *for an entry* when it uses one. The distinction has to
* come out of the drawing rather than a label: a summary spread of figures
* and country outlines reads perfectly well on its own, and dropping a day's
* photographs into it would be nonsense.
*/
it('refuses one that uses nothing from the entry', () => {
const summary = template([panelEl, el({ kind: 'text', text: 'SUMMARY', size: 0.1 })])
expect(templateFit(summary, entry())).toBe(-1)
})
it('refuses one that wants a story from an entry with none', () => {
expect(templateFit(template([storyEl]), entry({ story: null }))).toBe(-1)
})
/* Two empty frames on a page with one picture is a finished page with a hole. */
it('refuses one with more frames than the entry can fill', () => {
expect(templateFit(template([frameEl, frameEl, frameEl]), entry({ photos: [] }))).toBe(-1)
})
it('prefers the one whose frame count matches', () => {
const photos = [
{ photoId: 1, width: null, height: null },
{ photoId: 2, width: null, height: null },
]
const two = templateFit(template([frameEl, frameEl]), entry({ photos }))
const one = templateFit(template([frameEl]), entry({ photos }))
expect(two).toBeGreaterThan(one)
})
it('accepts more pictures than frames — the rest are parked', () => {
const photos = Array.from({ length: 5 }, (_, i) => ({ photoId: i, width: null, height: null }))
expect(templateFit(template([frameEl]), entry({ photos }))).toBeGreaterThanOrEqual(0)
})
})
@@ -0,0 +1,173 @@
import { describe, it, expect, vi } from 'vitest'
/*
* These cases are about the layouts written in autoLayout.ts, which are what an
* entry falls back on when no hand-drawn template suits it. With the real
* templates in place they would be testing whichever spread somebody last drew
* in Studio — so the set is empty here on purpose.
*/
vi.mock('../../../src/components/Studio/bookTemplates.data', () => ({ SPREAD_TEMPLATES: [] }))
import { bookPageSetupSchema, type BookSpread } from '@trek/shared'
import { buildBook, type AutoEntry, type AutoInput } from '../../../src/components/Studio/autoLayout'
/**
* A journey with little in it (#1973).
*
* A journey built from a trip starts as one skeleton entry per stop: a date, a
* place name, nothing else. Each of those used to take a spread of its own — a
* numeral on the left, a heading on the right, and two-thirds of a metre of
* empty paper. Ten stops made ten of them, and the book was a stack of blanks.
*
* They share a page now. What follows is that, and the two things it must not
* break: the order the journey happened in, and the entries that do have
* something on them.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210 })
const bare = (id: number, over: Partial<AutoEntry> = {}): AutoEntry => ({
id, title: `Stop ${id}`, story: null, location: 'Berlin', date: '2026-07-07', photos: [], ...over,
})
const rich = (id: number): AutoEntry => ({
id, title: `Day ${id}`, story: 'Something actually happened here.',
location: 'Berlin', date: '2026-07-08', photos: [],
})
function build(entries: AutoEntry[]): BookSpread[] {
const input: AutoInput = {
locale: 'en', title: 'T', subtitle: null, coverPhotoId: null,
entries, page, stats: null, stationsLabel: 'Stations', dayLabel: 'DAY', summaryLabel: 'Trip summary', countriesLabel: 'Countries',
}
return buildBook(input).spreads
}
/** The inner spreads, which is where entries land. */
const inner = (spreads: BookSpread[]) => spreads.filter(s => s.role === 'inner')
describe('entries with nothing on them', () => {
it('share one page instead of taking one each', () => {
const spreads = inner(build([bare(1), bare(2), bare(3), bare(4), bare(5)]))
expect(spreads).toHaveLength(1)
})
it('all appear on it', () => {
const spreads = inner(build([bare(1), bare(2), bare(3)]))
const words = spreads[0].elements.map(e => (e as { text?: string }).text ?? '').join(' ')
expect(words).toContain('Stop 1')
expect(words).toContain('Stop 2')
expect(words).toContain('Stop 3')
})
it('carries the heading it was given, translated by the caller', () => {
const spreads = inner(build([bare(1)]))
const words = spreads[0].elements.map(e => (e as { text?: string }).text ?? '').join(' ')
expect(words).toContain('STATIONS')
})
/* Nothing to regenerate a list from, so it is not tied to a single entry. */
it('belongs to no single entry', () => {
expect(inner(build([bare(1), bare(2)]))[0].entryId).toBeNull()
})
it('runs onto a second page when there are more than fit', () => {
const many = Array.from({ length: 30 }, (_, i) => bare(i + 1))
expect(inner(build(many))).toHaveLength(2)
})
/*
* The old behaviour, restated as the thing that must not come back: one
* spread per stop, each of them nearly empty.
*/
it('does not give ten stops ten spreads', () => {
const spreads = inner(build(Array.from({ length: 10 }, (_, i) => bare(i + 1))))
expect(spreads.length).toBeLessThan(10)
})
})
describe('mixed with real entries', () => {
it('leaves an entry with a story its own spread', () => {
const spreads = inner(build([rich(1)]))
expect(spreads).toHaveLength(1)
expect(spreads[0].entryId).toBe(1)
})
/*
* The order the journey happened in is the order the book is in. Gathering
* every bare entry at the end would be tidier and wrong.
*/
it('keeps the run where it happened, between the entries around it', () => {
const spreads = inner(build([rich(1), bare(2), bare(3), rich(4)]))
expect(spreads.map(s => s.entryId)).toEqual([1, null, 4])
})
it('starts a new list after each entry that interrupts one', () => {
const spreads = inner(build([bare(1), rich(2), bare(3)]))
expect(spreads.map(s => s.entryId)).toEqual([null, 2, null])
})
/* A photograph is substance too, even with nothing written. */
it('treats an entry with a photo as one worth a spread', () => {
const withPhoto: AutoEntry = { ...bare(1), photos: [{ photoId: 5, width: 4000, height: 3000 }] }
expect(inner(build([withPhoto]))[0].entryId).toBe(1)
})
})
describe('an entry with words but no pictures', () => {
/*
* An entry with no photographs is usually one whose photographs have not been
* added yet. The page it deserves is the one it will look like once they are,
* so the layout puts the frames in and leaves them empty — the renderer draws
* them as outlines and a dropped photo fills one.
*/
it('lays out the frames the pictures will go in', () => {
const spread = inner(build([rich(1)]))[0]
const frames = spread.elements.filter(e => e.kind === 'photo')
expect(frames.length).toBeGreaterThan(0)
expect(frames.every(f => (f as { photoId: number | null }).photoId === null)).toBe(true)
})
it('gives the frame most of a page rather than a token strip', () => {
const spread = inner(build([rich(1)]))[0]
const frame = spread.elements.find(e => e.kind === 'photo')!.frame
// A page is 210 × 210. Anything much smaller is decoration, not somewhere
// a photograph goes.
expect(frame.w).toBeGreaterThan(150)
expect(frame.h).toBeGreaterThan(150)
})
/*
* The bug this replaced: everything sat in the middle of the left page and
* the right one was empty, because the layout used half a page width where
* it meant the far page. A spread has two pages and both get used.
*/
it('uses both pages, picture on the left and words on the right', () => {
const spread = inner(build([rich(1)]))[0]
const frame = spread.elements.find(e => e.kind === 'photo')!.frame
expect(frame.x).toBeLessThan(210)
const words = spread.elements.filter(e => e.kind === 'text')
expect(words.length).toBeGreaterThan(0)
// Every line of type starts on the right-hand page.
expect(words.every(w => w.frame.x >= 210)).toBe(true)
})
it('keeps everything inside the sheet', () => {
const spread = inner(build([rich(1)]))[0]
expect(spread.elements.every(e => e.frame.x + e.frame.w <= 420 && e.frame.y + e.frame.h <= 210)).toBe(true)
})
it('still writes the story', () => {
const spread = inner(build([rich(1)]))[0]
const words = spread.elements.map(e => (e as { text?: string }).text ?? '').join(' ')
expect(words).toContain('Something actually happened here.')
})
})
describe('a journey with nothing at all', () => {
it('produces a book of a cover and a back, and no blank pages between', () => {
const spreads = build([])
expect(inner(spreads)).toHaveLength(0)
expect(spreads.map(s => s.role)).toEqual(['cover', 'back'])
})
})
@@ -0,0 +1,279 @@
import { describe, it, expect, vi } from 'vitest'
/*
* These cases are about the layouts written in autoLayout.ts, which are what an
* entry falls back on when no hand-drawn template suits it. With the real
* templates in place they would be testing whichever spread somebody last drew
* in Studio — so the set is empty here on purpose.
*/
vi.mock('../../../src/components/Studio/bookTemplates.data', () => ({ SPREAD_TEMPLATES: [] }))
import type { BookPageSetup, BookSpread, JourneyStats } from '@trek/shared'
import { buildBook, type AutoEntry, type AutoInput } from '../../../src/components/Studio/autoLayout'
import { bookPageSetupSchema } from '@trek/shared'
/**
* The auto layout, once it knows what the journey adds up to (#1973).
*
* The rule these all circle: a page is added because there is something to put
* on it. A summary spread over a journey with no coordinates, or a country page
* for a trip that never left one country, is padding — and padding in a book
* someone pays to have printed is worse than a shorter book.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
})
const entry = (over: Partial<AutoEntry> = {}): AutoEntry => ({
id: 1, title: 'A day', story: 'Something happened.', location: 'Reykjavík',
date: '2026-06-02', photos: [], ...over,
})
const stats = (over: Partial<JourneyStats> = {}): JourneyStats => ({
journeyId: 6,
distance: 1_189_000,
days: 14,
steps: 14,
photos: 57,
places: 21,
furthest: 408_000,
countries: [{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' }],
points: [
{ lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS' },
{ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS' },
],
start: '2026-06-02',
end: '2026-06-15',
...over,
})
function build(over: Partial<AutoInput> = {}) {
return buildBook({
locale: 'en',
title: 'Iceland',
subtitle: null,
coverPhotoId: null,
stationsLabel: 'Stations',
dayLabel: 'DAY',
summaryLabel: 'Trip summary',
countriesLabel: 'Countries',
entries: [entry()],
page,
stats: stats(),
...over,
})
}
type ElementKind = BookSpread['elements'][number]['kind']
const kinds = (sp: BookSpread) => sp.elements.map(e => e.kind)
/** `Array.prototype.at` is ES2022; the client's lib target predates it. */
const last = <T,>(xs: T[]): T => xs[xs.length - 1]
const has = (doc: { spreads: BookSpread[] }, kind: ElementKind) =>
doc.spreads.some(sp => kinds(sp).includes(kind))
const find = (doc: { spreads: BookSpread[] }, kind: ElementKind) =>
doc.spreads.flatMap(sp => sp.elements).find(e => e.kind === kind)
describe('the summary spread', () => {
it('is placed, with the route and the figures', () => {
const doc = build()
expect(has(doc, 'map')).toBe(true)
expect(has(doc, 'stats')).toBe(true)
})
it('comes straight after the cover, before the entries', () => {
const doc = build()
expect(doc.spreads[0].role).toBe('cover')
expect(kinds(doc.spreads[1])).toContain('map')
})
it('carries the journey figures rather than placeholders', () => {
const el = find(build(), 'stats')!
expect(el.kind === 'stats' && el.values.distance).toBe(1_189_000)
expect(el.kind === 'stats' && el.values.days).toBe(14)
})
it('draws the route from the journey stops', () => {
const el = find(build(), 'map')!
expect(el.kind === 'map' && el.points).toHaveLength(2)
expect(el.kind === 'map' && el.countries).toEqual(['IS'])
})
/*
* The point of the whole feature: a figure that is zero is not a fact worth
* a tile, it is a hole in the composition.
*/
it('leaves out the figures the journey does not have', () => {
const el = find(build({ stats: stats({ photos: 0, furthest: 0 }) }), 'stats')!
expect(el.kind === 'stats' && el.metrics).not.toContain('photos')
expect(el.kind === 'stats' && el.metrics).not.toContain('furthest')
expect(el.kind === 'stats' && el.metrics).toContain('distance')
})
it('has no map when the journey has no route to draw', () => {
const doc = build({ stats: stats({ points: [] }) })
expect(has(doc, 'map')).toBe(false)
// The figures are still worth a page.
expect(has(doc, 'stats')).toBe(true)
})
it('is skipped entirely when there is nothing to summarise', () => {
const doc = build({
stats: stats({
points: [], distance: 0, days: 0, steps: 0, photos: 0, places: 0, furthest: 0, countries: [],
}),
})
// Cover, the one entry, back cover — no summary, no country page.
expect(doc.spreads.map(sp => sp.role)).toEqual(['cover', 'inner', 'back'])
expect(has(doc, 'map')).toBe(false)
expect(has(doc, 'stats')).toBe(false)
})
it('is skipped when the figures could not be fetched at all', () => {
const doc = build({ stats: null })
expect(has(doc, 'map')).toBe(false)
expect(has(doc, 'stats')).toBe(false)
expect(has(doc, 'countries')).toBe(false)
// And the book still builds: cover, the entry, back cover.
expect(doc.spreads).toHaveLength(3)
})
})
describe('the country page', () => {
const twoCountries = stats({
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
{ code: 'NO', name: 'Norway', places: 3, firstVisit: '2026-06-16' },
],
})
it('is placed for a journey that crossed a border', () => {
const el = find(build({ stats: twoCountries }), 'countries')!
expect(el.kind === 'countries' && el.codes).toEqual(['IS', 'NO'])
})
/*
* One country on a page of its own says nothing the cover did not already
* say, and every page in a printed book costs money.
*/
it('is skipped for a journey that stayed in one country', () => {
expect(has(build(), 'countries')).toBe(false)
})
it('names the countries in the book language, not the API language', () => {
const el = find(build({ stats: twoCountries, locale: 'de' }), 'countries')!
expect(el.kind === 'countries' && el.names).toEqual(['Island', 'Norwegen'])
})
it('falls back to the API name when the locale has none', () => {
const el = find(build({ stats: twoCountries, locale: 'en' }), 'countries')!
expect(el.kind === 'countries' && el.names).toEqual(['Iceland', 'Norway'])
})
it('sits between the summary and the entries', () => {
const doc = build({ stats: twoCountries })
const order = doc.spreads.map(sp => kinds(sp).find(k => k === 'map' || k === 'countries') ?? sp.role)
expect(order.slice(0, 3)).toEqual(['cover', 'map', 'countries'])
})
})
describe('the back cover', () => {
/*
* It used to build its tally as `${places} Orte` — German, hardcoded, in a
* book that follows the app's language everywhere else.
*/
it('closes with a figures element rather than a built string', () => {
const back = last(build().spreads)
expect(back.role).toBe('back')
expect(kinds(back)).toContain('stats')
expect(back.elements.every(e => e.kind !== 'text' || !/Orte|Fotos/.test(e.text))).toBe(true)
})
it('sets that tally quietly, against the dark card', () => {
const back = last(build().spreads)
const el = back.elements.find(e => e.kind === 'stats')!
expect(el.kind === 'stats' && el.showIcons).toBe(false)
expect(el.opacity).toBeLessThan(1)
})
it('still closes the book when there are no figures', () => {
const back = last(build({ stats: null }).spreads)
expect(back.role).toBe('back')
expect(kinds(back)).toContain('text')
})
})
describe('the entry spreads', () => {
it('rules the heading with a short accent line', () => {
const doc = build()
const spread = doc.spreads.find(sp => sp.entryId === 1)!
const rule = spread.elements.find(e => e.kind === 'shape' && e.frame.h < 1)
expect(rule).toBeTruthy()
expect(rule!.frame.w).toBeLessThan(20)
})
/*
* The one spread with room to spare — the left page is otherwise blank, and
* a date set as a numeral turns a gap in the book into a pause in it.
*/
it('sets the date as a numeral on an entry with no photographs', () => {
const doc = build()
const spread = doc.spreads.find(sp => sp.entryId === 1)!
const badge = spread.elements.find(e => e.kind === 'badge')
expect(badge).toBeTruthy()
expect(badge!.kind === 'badge' && badge.variant).toBe('date')
expect(badge!.kind === 'badge' && badge.text).toBe('2')
expect(badge!.kind === 'badge' && badge.sub).toBe('JUNE')
})
it('sets that numeral in the book language', () => {
const doc = build({ locale: 'de' })
const spread = doc.spreads.find(sp => sp.entryId === 1)!
const badge = spread.elements.find(e => e.kind === 'badge')!
expect(badge.kind === 'badge' && badge.sub).toBe('JUNI')
})
it('leaves out the numeral on an undated entry rather than printing a blank', () => {
const doc = build({ entries: [entry({ date: null })] })
const spread = doc.spreads.find(sp => sp.entryId === 1)!
expect(spread.elements.some(e => e.kind === 'badge')).toBe(false)
})
/*
* The marks came back on purpose. A page carrying a picture, a date numeral
* and a day chip is what a printed travel book looks like; the same page with
* only the picture is what a document looks like.
*/
it('gives a photo entry the marks that make it a printed page', () => {
const doc = build({
entries: [entry({ photos: [{ photoId: 1, width: 4000, height: 3000, caption: null }] })],
})
const spread = doc.spreads.find(sp => sp.entryId === 1)!
expect(spread.elements.some(e => e.kind === 'photo')).toBe(true)
expect(spread.elements.some(e => e.kind === 'badge')).toBe(true)
})
})
describe('the document as a whole', () => {
it('stays within the schema cap on spreads', () => {
const many = Array.from({ length: 200 }, (_, i) => entry({ id: i + 1 }))
expect(build({ entries: many }).spreads.length).toBeLessThanOrEqual(150)
})
it('gives every spread and every element an id of its own', () => {
const doc = build({ stats: stats({ countries: [
{ code: 'IS', name: 'Iceland', places: 4, firstVisit: '2026-06-02' },
{ code: 'NO', name: 'Norway', places: 2, firstVisit: '2026-06-16' },
] }) })
const ids = doc.spreads.flatMap(sp => [sp.id, ...sp.elements.map(e => e.id)])
expect(new Set(ids).size).toBe(ids.length)
})
it('opens on the cover and closes on the back cover', () => {
const doc = build()
expect(doc.spreads[0].role).toBe('cover')
expect(last(doc.spreads).role).toBe('back')
expect(doc.spreads.filter(sp => sp.role === 'cover')).toHaveLength(1)
expect(doc.spreads.filter(sp => sp.role === 'back')).toHaveLength(1)
})
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect } from 'vitest'
import { BOOK_FONTS_IDS, bookTextElementSchema } from '@trek/shared'
import {
BOOK_FONTS, BOOK_FONT_ORDER, fontStack, hasWeight, nearestWeight,
} from '../../../src/components/Studio/bookFonts'
/**
* The book's typefaces (#1973).
*
* The rule the whole file exists for: a book is proofread on screen and printed
* somewhere else, so every family has to be one both machines have. A stack
* naming a system font — Georgia, which is on most desktops and on no Linux
* container — means the renderer substitutes silently and the printed book is
* set in a typeface nobody chose.
*/
describe('the registry', () => {
it('has an entry for every family the contract allows', () => {
for (const id of BOOK_FONTS_IDS) {
expect(BOOK_FONTS[id], id).toBeTruthy()
}
})
it('offers every family in the picker, in one order', () => {
expect([...BOOK_FONT_ORDER].sort()).toEqual([...BOOK_FONTS_IDS].sort())
expect(new Set(BOOK_FONT_ORDER).size).toBe(BOOK_FONT_ORDER.length)
})
/*
* The one that matters. Every stack must end in a generic — a stack ending in
* another named family is a stack that can silently land somewhere nobody
* bundled.
*/
it('ends every stack in a generic family', () => {
for (const id of BOOK_FONTS_IDS) {
const last = BOOK_FONTS[id].stack.split(',').pop()!.trim()
expect(['sans-serif', 'serif', 'monospace'], `${id}: ${last}`).toContain(last)
}
})
it('names a bundled family first in every stack', () => {
// The first entry is the one that will actually be used, so it is the one
// that has to be self-hosted.
const bundled = ['Poppins', 'Inter', 'Lora', 'EB Garamond', 'Playfair Display', 'MuseoModerno', 'Bebas Neue']
for (const id of BOOK_FONTS_IDS) {
const first = BOOK_FONTS[id].stack.split(',')[0].replace(/"/g, '').trim()
expect(bundled, `${id}: ${first}`).toContain(first)
}
})
it('keeps the three original ids, so old documents still parse', () => {
for (const id of ['sans', 'serif', 'display']) {
expect(BOOK_FONTS_IDS).toContain(id)
}
expect(bookTextElementSchema.parse({
id: 't', kind: 'text', frame: { x: 0, y: 0, w: 10, h: 10 }, font: 'serif',
}).font).toBe('serif')
})
it('no longer sets the serif slot in a system font', () => {
expect(BOOK_FONTS.serif.stack).toContain('Lora')
expect(BOOK_FONTS.serif.stack.split(',')[0]).not.toContain('Georgia')
})
it('gives every family at least one weight', () => {
for (const id of BOOK_FONTS_IDS) {
expect(BOOK_FONTS[id].weights.length, id).toBeGreaterThan(0)
}
})
})
describe('fontStack', () => {
it('resolves a known family', () => {
expect(fontStack('garamond')).toContain('EB Garamond')
})
/*
* A document naming a family that was later removed should render in
* something bundled, not in whatever the machine defaults to.
*/
it('falls back to a bundled family rather than to a generic', () => {
expect(fontStack('a-family-that-was-removed')).toBe(BOOK_FONTS.sans.stack)
})
})
describe('weights', () => {
it('reports what a family actually ships', () => {
expect(hasWeight('sans', 700)).toBe(true)
expect(hasWeight('bebas', 700)).toBe(false)
expect(hasWeight('bebas', 400)).toBe(true)
})
/*
* A synthesised bold is a smeared regular in print, so picking a display face
* that ships one weight has to move the weight rather than ask for one the
* renderer will fake.
*/
it('moves to the nearest weight a family really has', () => {
expect(nearestWeight('bebas', 700)).toBe(400)
expect(nearestWeight('sans', 700)).toBe(700)
})
it('leaves an unknown family alone rather than guessing', () => {
expect(nearestWeight('unknown', 600)).toBe(600)
expect(hasWeight('unknown', 600)).toBe(true)
})
})
+119
View File
@@ -0,0 +1,119 @@
import { describe, it, expect } from 'vitest'
import type { BookDocument, BookSpread } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { MARK_LENGTH, sheetBox, sheetsFor } from '../../../src/components/Studio/bookSheets'
/**
* Cutting a book into sheets (#1973).
*
* The arithmetic here is what a press measures, so the tests are about
* millimetres: where the trim edge lands on the sheet, how far the marks reach,
* and which half of a spread each leaf shows. Getting the offset wrong is not a
* visible bug on screen — it is a photograph that arrives cut in the wrong
* place, in a book someone paid to have printed.
*/
const page = (over: Record<string, unknown> = {}) =>
bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, ...over })
const spread = (role: BookSpread['role'], id: string): BookSpread => ({
id, role, background: null, elements: [], parked: [], entryId: null,
})
function book(roles: BookSpread['role'][], pageOver: Record<string, unknown> = {}): BookDocument {
return {
version: 1,
title: 'T',
page: page(pageOver),
spreads: roles.map((r, i) => spread(r, `s${i}`)),
} as BookDocument
}
describe('the sheet box', () => {
it('is the trim plus a bleed on each side when there are no marks', () => {
const box = sheetBox(210, 297, 3, false)
expect(box.width).toBe(216)
expect(box.height).toBe(303)
expect(box.margin).toBe(3)
})
/*
* Marks go outside the bleed, not inside it. Inside means printing them onto
* the strip that gets cut off — they would be trimmed away along with the
* thing they were marking.
*/
it('makes room outside the bleed for the marks', () => {
const box = sheetBox(210, 210, 3, true)
expect(box.margin).toBe(3 + MARK_LENGTH)
expect(box.width).toBe(210 + (3 + MARK_LENGTH) * 2)
// The bleed is still the bleed — the extra room is not more of it.
expect(box.bleed).toBe(3)
})
it('is the trim itself when the book has no bleed at all', () => {
const box = sheetBox(210, 210, 0, false)
expect(box.width).toBe(210)
expect(box.margin).toBe(0)
})
})
describe('single pages', () => {
it('cuts every inner spread into two leaves and leaves the covers whole', () => {
const sheets = sheetsFor(book(['cover', 'inner', 'inner', 'back']), 'pages')
expect(sheets).toHaveLength(1 + 2 + 2 + 1)
expect(sheets.map(s => s.width)).toEqual([210, 210, 210, 210, 210, 210])
})
/*
* The cut is a window onto the spread, never a re-layout: the right-hand leaf
* is the same spread shifted by one page width. That is what makes a
* photograph crossing the gutter line up again once both halves are printed.
*/
it('shows the right-hand leaf through a window one page further in', () => {
const sheets = sheetsFor(book(['cover', 'inner']), 'pages')
const [, left, right] = sheets
expect(left.offset).toBe(0)
expect(right.offset).toBe(210)
// Both halves are the same spread, so nothing was moved to make them fit.
expect(right.spread).toBe(left.spread)
expect(right.spreadWidth).toBe(420)
})
it('keeps a cover at one page wide, with nothing shifted', () => {
const [cover] = sheetsFor(book(['cover', 'inner']), 'pages')
expect(cover.width).toBe(210)
expect(cover.spreadWidth).toBe(210)
expect(cover.offset).toBe(0)
})
/* Labels are for the print view. Covers carry no folio, in any book. */
it('numbers the leaves the way the folios do, and leaves covers unlabelled', () => {
const sheets = sheetsFor(book(['cover', 'inner', 'inner']), 'pages')
expect(sheets.map(s => s.label)).toEqual(['', '2', '3', '4', '5'])
})
it('follows startAt when the book counts from somewhere else', () => {
const sheets = sheetsFor(book(['cover', 'inner'], { pageNumbers: { startAt: 1 } }), 'pages')
expect(sheets.map(s => s.label)).toEqual(['', '1', '2'])
})
})
describe('spreads', () => {
it('keeps each spread on one sheet', () => {
const sheets = sheetsFor(book(['cover', 'inner', 'inner', 'back']), 'spreads')
expect(sheets).toHaveLength(4)
expect(sheets.map(s => s.width)).toEqual([210, 420, 420, 210])
expect(sheets.every(s => s.offset === 0)).toBe(true)
})
it('labels an inner sheet with the pair of numbers it carries', () => {
const sheets = sheetsFor(book(['cover', 'inner', 'inner']), 'spreads')
expect(sheets.map(s => s.label)).toEqual(['', '2 – 3', '4 – 5'])
})
})
describe('an empty book', () => {
it('produces no sheets rather than one blank one', () => {
expect(sheetsFor(book([]), 'pages')).toEqual([])
})
})
@@ -0,0 +1,186 @@
import { describe, it, expect } from 'vitest'
import type { BookElement, BookPageSetup, BookSpread } from '@trek/shared'
import { bookListElementSchema, normalizeBookDocument } from '@trek/shared'
import { render } from '../../helpers/render'
import { SpreadView } from '../../../src/components/Studio/SpreadView'
import { bookPageSetupSchema } from '@trek/shared'
/**
* Mood, weather and pros & cons (#1973).
*
* These come from the journal entry rather than from the trip, and they are the
* part of an entry a photo cannot carry. What is checked here is that they draw
* from the entry's own vocabulary — the same keys, icons and palette the journey
* page uses — rather than from a second copy that would drift.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
})
const typeset = { font: 'sans' as const, color: '#1a1a1a', accent: '#c2410c', textScale: 1, stale: false }
const common = { rotation: 0, opacity: 1, locked: false, frame: { x: 10, y: 20, w: 90, h: 40 } }
function draw(elements: BookElement[]) {
const spread: BookSpread = {
id: 's1', role: 'inner', background: null, elements, parked: [], entryId: null,
}
return render(<SpreadView spread={spread} page={page} />)
}
const badge = (over: Record<string, unknown>): BookElement => ({
...common, ...typeset, id: 'b1', kind: 'badge',
variant: 'mood', text: '', sub: '', code: null, style: 'plain', ...over,
} as unknown as BookElement)
const list = (over: Record<string, unknown> = {}): BookElement => ({
...common, ...typeset, id: 'l1', kind: 'list',
items: [
{ text: 'Warm all week', tone: 'pro' },
{ text: 'Ferry was late', tone: 'con' },
],
layout: 'columns', showMarks: true, proLabel: 'Pros', conLabel: 'Cons', ...over,
} as unknown as BookElement)
describe('the mood mark', () => {
it('draws the icon the journey page uses for that mood', () => {
const { container } = draw([badge({ variant: 'mood', code: 'good', text: 'Good' })])
expect(container.querySelector('svg')).not.toBeNull()
expect(container.textContent).toContain('Good')
})
/*
* The palette comes from MOOD_CONFIG rather than from the element's accent:
* a mood has a colour of its own in TREK, and a book that ignored it would
* disagree with the page the entry was written on.
*/
it('takes its colour from the mood, not from the element accent', () => {
const { container } = draw([badge({ variant: 'mood', code: 'good', text: 'Good' })])
const icon = container.querySelector('svg')!
expect(icon.getAttribute('stroke')).toBe('#B45309')
})
it('draws nothing for a mood key TREK does not know', () => {
const { container } = draw([badge({ variant: 'mood', code: 'elated', text: 'Elated' })])
expect(container.querySelector('svg')).toBeNull()
// The words still print — an unknown key is not a reason to lose the label.
expect(container.textContent).toContain('Elated')
})
})
describe('the weather mark', () => {
it('draws the icon for that weather', () => {
const { container } = draw([badge({ variant: 'weather', code: 'cloudy', text: 'Cloudy' })])
expect(container.querySelector('svg')).not.toBeNull()
expect(container.textContent).toContain('Cloudy')
})
it('uses the element accent, since weather has no palette of its own', () => {
const { container } = draw([badge({ variant: 'weather', code: 'sunny', text: 'Sunny' })])
expect(container.querySelector('svg')!.getAttribute('stroke')).toBe('#c2410c')
})
})
describe('the pros and cons list', () => {
it('prints both columns with their headings', () => {
const { container } = draw([list()])
expect(container.textContent).toContain('Warm all week')
expect(container.textContent).toContain('Ferry was late')
expect(container.textContent).toContain('Pros')
expect(container.textContent).toContain('Cons')
})
it('marks each line, a plus against a dash', () => {
const { container } = draw([list()])
expect(container.textContent).toContain('+')
// An en dash, not a hyphen — at 2mm a hyphen reads as a speck.
expect(container.textContent).toContain('–')
})
it('drops the marks when they are switched off', () => {
const { container } = draw([list({ showMarks: false })])
expect(container.textContent).toContain('Warm all week')
expect(container.textContent).not.toContain('+')
})
it('prints a one-sided list without an empty second column', () => {
const { container } = draw([list({
items: [{ text: 'Only good things', tone: 'pro' }],
conLabel: '',
})])
expect(container.textContent).toContain('Only good things')
expect(container.textContent).not.toContain('Cons')
})
it('runs as one column when stacked', () => {
const { container } = draw([list({ layout: 'stacked' })])
expect(container.textContent).toContain('Warm all week')
expect(container.textContent).toContain('Ferry was late')
})
it('survives an empty list rather than failing to draw', () => {
const { container } = draw([list({ items: [], proLabel: '', conLabel: '' })])
expect(container.querySelector('div')).not.toBeNull()
})
})
describe('the list contract', () => {
it('defaults to an empty two-column list with marks', () => {
const parsed = bookListElementSchema.parse({
id: 'l1', kind: 'list', frame: { x: 0, y: 0, w: 10, h: 10 },
})
expect(parsed.items).toEqual([])
expect(parsed.layout).toBe('columns')
expect(parsed.showMarks).toBe(true)
})
it('defaults an item with no tone to plain', () => {
const parsed = bookListElementSchema.parse({
id: 'l1', kind: 'list', frame: { x: 0, y: 0, w: 10, h: 10 },
items: [{ text: 'something' }],
})
expect(parsed.items[0].tone).toBe('plain')
})
/*
* The rule the whole format rests on: a document written before these
* elements existed still opens, and one written with them survives a round
* trip through storage.
*/
it('round-trips inside a document', () => {
const doc = normalizeBookDocument({
version: 1,
title: 'T',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{
id: 's1', role: 'inner', entryId: null, background: null, parked: [],
elements: [{
id: 'l1', kind: 'list', frame: { x: 1, y: 2, w: 90, h: 40 },
items: [{ text: 'kept', tone: 'con' }],
proLabel: 'P', conLabel: 'C',
}],
}],
})
const el = doc.spreads[0].elements[0]
expect(el.kind).toBe('list')
expect(el.kind === 'list' && el.items[0]).toEqual({ text: 'kept', tone: 'con' })
expect(el.kind === 'list' && el.conLabel).toBe('C')
})
it('still opens a document that predates these elements', () => {
const doc = normalizeBookDocument({
version: 1,
title: 'Old',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads: [{
id: 's1', role: 'inner', entryId: null, background: null, parked: [],
elements: [{ id: 'p1', kind: 'photo', frame: { x: 0, y: 0, w: 50, h: 50 }, photoId: 3 }],
}],
})
const el = doc.spreads[0].elements[0]
expect(el.kind).toBe('photo')
// The fields added since default in rather than failing the parse.
expect(el.kind === 'photo' && el.mask).toBeNull()
expect(el.kind === 'photo' && el.frameStyle).toBe('none')
})
})
+171
View File
@@ -0,0 +1,171 @@
import { describe, it, expect } from 'vitest'
import { COUNTRY_SHAPES } from '../../../src/components/Studio/countryShapes'
import {
FLAG_H, FLAG_SPECS, FLAG_W, flagBands, flagDisc, flagSpec,
} from '../../../src/components/Studio/flags'
/**
* The drawn flags (#1973).
*
* The rule that matters is the one about restraint: a flag is listed only when
* its construction is exact. A wrong flag in a printed book is worse than a
* country outline, so the guard here is as much about what is *absent* as
* about what is drawn.
*/
describe('the flag table', () => {
it('uses two-letter uppercase codes throughout', () => {
for (const code of Object.keys(FLAG_SPECS)) {
expect(code, code).toMatch(/^[A-Z]{2}$/)
}
})
it('gives every colour as #rrggbb, so it can be handed straight to SVG', () => {
for (const [code, spec] of Object.entries(FLAG_SPECS)) {
const colours = spec.t === 'h' || spec.t === 'v'
? spec.c
: spec.t === 'nordic'
? [spec.field, spec.cross, ...(spec.inner ? [spec.inner] : [])]
: spec.t === 'cross'
? [spec.field, spec.cross]
: [spec.field, spec.disc]
for (const c of colours) expect(c, `${code}: ${c}`).toMatch(/^#[0-9a-f]{6}$/)
}
})
it('has at least two bands on any striped flag', () => {
for (const [code, spec] of Object.entries(FLAG_SPECS)) {
if (spec.t === 'h' || spec.t === 'v') {
expect(spec.c.length, code).toBeGreaterThanOrEqual(2)
}
}
})
/*
* The point of the whole approach: anything that cannot be built from bands,
* a cross or a disc is left out rather than approximated. These are the flags
* most likely to be reached for and wrongly added — each carries an emblem or
* unequal bands that this construction cannot express.
*/
it('leaves out the flags it cannot draw exactly', () => {
for (const code of ['GB', 'US', 'ES', 'PT', 'MX', 'BR', 'IN', 'EG', 'CA', 'AU', 'CO', 'LV', 'GR', 'TR']) {
expect(FLAG_SPECS[code], code).toBeUndefined()
}
})
it('has a country silhouette for every flag it does not draw', () => {
// The fallback has to exist, or a missing flag becomes bare letters.
for (const code of ['GB', 'US', 'ES', 'PT', 'MX', 'BR', 'IN', 'CA', 'AU', 'TR', 'GR']) {
expect(COUNTRY_SHAPES[code], code).toBeTruthy()
}
})
})
describe('flagBands', () => {
it('splits horizontal bands evenly across the height', () => {
const bands = flagBands({ t: 'h', c: ['#000000', '#dd0000', '#ffce00'] })
expect(bands).toHaveLength(3)
for (const b of bands) {
expect(b.w).toBe(FLAG_W)
expect(b.h).toBeCloseTo(FLAG_H / 3, 6)
}
expect(bands[0].y).toBe(0)
expect(bands[2].y + bands[2].h).toBeCloseTo(FLAG_H, 6)
})
it('splits vertical bands evenly across the width', () => {
const bands = flagBands({ t: 'v', c: ['#002395', '#ffffff', '#ed2939'] })
for (const b of bands) {
expect(b.h).toBe(FLAG_H)
expect(b.w).toBeCloseTo(FLAG_W / 3, 6)
}
expect(bands[2].x + bands[2].w).toBeCloseTo(FLAG_W, 6)
})
it('keeps the band order, which is the whole identity of a tricolour', () => {
const bands = flagBands(FLAG_SPECS.DE)
expect(bands.map(b => b.fill)).toEqual(['#000000', '#dd0000', '#ffce00'])
})
/*
* Left of centre is what separates a Nordic cross from a plain one — the
* upright at the halfway mark would be the Swiss flag's geometry, not
* Denmark's.
*/
it('puts the Nordic upright left of centre', () => {
const bands = flagBands(FLAG_SPECS.DK)
const upright = bands[1]
expect(upright.x + upright.w / 2).toBeLessThan(FLAG_W / 2)
expect(upright.h).toBe(FLAG_H)
})
it('draws the inner cross on top of the outer one, and thinner', () => {
const bands = flagBands(FLAG_SPECS.IS)
// field, upright, crossbar, then the two inner bars.
expect(bands).toHaveLength(5)
expect(bands[3].fill).toBe('#dc1e35')
expect(bands[3].w).toBeLessThan(bands[1].w)
})
it('omits the inner cross where the flag has none', () => {
expect(flagBands(FLAG_SPECS.DK)).toHaveLength(3)
})
it('centres the Swiss cross both ways and keeps it inside the field', () => {
const [field, upright, bar] = flagBands(FLAG_SPECS.CH)
expect(field.w).toBe(FLAG_W)
expect(upright.x + upright.w / 2).toBeCloseTo(FLAG_W / 2, 6)
expect(bar.y + bar.h / 2).toBeCloseTo(FLAG_H / 2, 6)
expect(upright.y).toBeGreaterThan(0)
expect(upright.y + upright.h).toBeLessThan(FLAG_H)
})
it('keeps every band inside the flag box', () => {
for (const [code, spec] of Object.entries(FLAG_SPECS)) {
for (const b of flagBands(spec)) {
expect(b.x, code).toBeGreaterThanOrEqual(0)
expect(b.y, code).toBeGreaterThanOrEqual(0)
expect(b.x + b.w, code).toBeLessThanOrEqual(FLAG_W + 0.001)
expect(b.y + b.h, code).toBeLessThanOrEqual(FLAG_H + 0.001)
}
}
})
it('covers the whole box with its first band, so no page shows through', () => {
for (const [code, spec] of Object.entries(FLAG_SPECS)) {
const bands = flagBands(spec)
const area = bands.reduce((sum, b) => sum + b.w * b.h, 0)
expect(area, code).toBeGreaterThanOrEqual(FLAG_W * FLAG_H - 0.001)
}
})
})
describe('flagDisc', () => {
it('is a centred circle on a disc flag', () => {
const disc = flagDisc(FLAG_SPECS.JP)!
expect(disc.cx).toBe(FLAG_W / 2)
expect(disc.cy).toBe(FLAG_H / 2)
expect(disc.r).toBeLessThan(FLAG_H / 2)
expect(disc.fill).toBe('#bc002d')
})
it('is null for every other construction', () => {
expect(flagDisc(FLAG_SPECS.DE)).toBeNull()
expect(flagDisc(FLAG_SPECS.IS)).toBeNull()
expect(flagDisc(FLAG_SPECS.CH)).toBeNull()
})
})
describe('flagSpec', () => {
it('is case-insensitive, because codes arrive from several places', () => {
expect(flagSpec('is')).toBe(FLAG_SPECS.IS)
expect(flagSpec('IS')).toBe(FLAG_SPECS.IS)
})
it('is null for nothing and for a country with no construction', () => {
expect(flagSpec(null)).toBeNull()
expect(flagSpec(undefined)).toBeNull()
expect(flagSpec('')).toBeNull()
expect(flagSpec('GB')).toBeNull()
})
})
@@ -0,0 +1,133 @@
import { describe, it, expect } from 'vitest'
import type { BookElement, BookSpread } from '@trek/shared'
import { brightness, folioInk } from '../../../src/components/Studio/folioColour'
/**
* Choosing a folio colour (#1973).
*
* One fixed colour is wrong on half the pages of any real book: the warm grey
* that reads on paper vanishes into a full-bleed night photograph, and white
* vanishes the moment the page is white. So the renderer asks what is under the
* number. What follows is that question, and the order it has to be asked in.
*/
const DARK = '#3f3a33'
const LIGHT = '#ffffff'
const shape = (over: Partial<BookElement> = {}): BookElement => ({
id: 's', kind: 'shape', frame: { x: 0, y: 0, w: 100, h: 100 },
rotation: 0, opacity: 1, locked: false,
shape: 'rect', fill: '#000000', gradient: 'none',
stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0,
...over,
} as BookElement)
const photo = (over: Partial<BookElement> = {}): BookElement => ({
id: 'p', kind: 'photo', frame: { x: 0, y: 0, w: 100, h: 100 },
rotation: 0, opacity: 1, locked: false,
photoId: 1, fit: 'cover', focalX: 0.5, focalY: 0.5,
radius: 0, filter: 'none', mask: null, frameStyle: 'none',
...over,
} as BookElement)
const text = (over: Partial<BookElement> = {}): BookElement => ({
id: 't', kind: 'text', frame: { x: 0, y: 0, w: 100, h: 100 },
rotation: 0, opacity: 1, locked: false,
text: 'x', font: 'sans', size: 12, color: '#000000',
align: 'left', lineHeight: 1.3, letterSpacing: 0, weight: 400, italic: false,
...over,
} as unknown as BookElement)
const spread = (elements: BookElement[], background: string | null = '#ffffff'): BookSpread => ({
id: 's1', role: 'inner', background, elements, parked: [], entryId: null,
})
describe('brightness', () => {
it('reads the ends of the range', () => {
expect(brightness('#000000')).toBe(0)
expect(brightness('#ffffff')).toBe(1)
})
it('weights green the way an eye does', () => {
expect(brightness('#00ff00')).toBeGreaterThan(brightness('#ff0000'))
expect(brightness('#ff0000')).toBeGreaterThan(brightness('#0000ff'))
})
it('takes a three-digit hex', () => {
expect(brightness('#fff')).toBe(1)
})
})
describe('an empty page', () => {
it('takes dark ink on white paper', () => {
expect(folioInk(spread([]), 50, 50).color).toBe(DARK)
})
it('takes light ink on a dark page', () => {
expect(folioInk(spread([], '#101014'), 50, 50).color).toBe(LIGHT)
})
it('treats a page with no colour set as paper', () => {
expect(folioInk(spread([], null), 50, 50).color).toBe(DARK)
})
})
describe('under a shape', () => {
it('takes light ink over a dark fill', () => {
expect(folioInk(spread([shape({ fill: '#111111' })]), 50, 50).color).toBe(LIGHT)
})
it('takes dark ink over a pale fill, even on a dark page', () => {
const s = spread([shape({ fill: '#f5f0e8' })], '#101014')
expect(folioInk(s, 50, 50).color).toBe(DARK)
})
/* The array is paint order, so the last one drawn is the one you see. */
it('asks the topmost element, not the first', () => {
const s = spread([
shape({ id: 'under', fill: '#ffffff' }),
shape({ id: 'over', fill: '#000000' }),
])
expect(folioInk(s, 50, 50).color).toBe(LIGHT)
})
it('ignores a shape the number does not sit on', () => {
const s = spread([shape({ fill: '#000000', frame: { x: 0, y: 0, w: 20, h: 20 } })])
expect(folioInk(s, 80, 80).color).toBe(DARK)
})
/* A nearly transparent panel does not decide what is underneath it. */
it('looks past something barely there', () => {
const s = spread([shape({ fill: '#000000', opacity: 0.15 })])
expect(folioInk(s, 50, 50).color).toBe(DARK)
})
it('looks past a shape with no fill at all', () => {
const s = spread([shape({ fill: 'transparent' })], '#101014')
expect(folioInk(s, 50, 50).color).toBe(LIGHT)
})
})
describe('over a photograph', () => {
/*
* Unknowable without decoding it — and a printed photograph is dark far more
* often than not. White with a hairline of shadow reads on all of them.
*/
it('goes white, with a shadow to survive the pale ones', () => {
const ink = folioInk(spread([photo()]), 50, 50)
expect(ink.color).toBe(LIGHT)
expect(ink.shadow).toBeTruthy()
})
it('adds no shadow anywhere else', () => {
expect(folioInk(spread([]), 50, 50).shadow).toBeUndefined()
expect(folioInk(spread([shape({ fill: '#111' })]), 50, 50).shadow).toBeUndefined()
})
})
describe('type', () => {
/* A paragraph draws letters on nothing; the page behind it is the background. */
it('does not count as a background', () => {
expect(folioInk(spread([text()], '#101014'), 50, 50).color).toBe(LIGHT)
})
})
+178
View File
@@ -0,0 +1,178 @@
import { describe, it, expect } from 'vitest'
import type { BookPageSetup } from '@trek/shared'
import { GRIDS, defaultGridBox, gridElements } from '../../../src/components/Studio/grids'
import { bookPageSetupSchema } from '@trek/shared'
/**
* Photo grids (#1973).
*
* The rules worth pinning are geometric: a grid must tile the box it is given
* without gaps or overlaps, and the block it produces must occupy exactly that
* box — a grid asked to fill the safe area that overhangs it by one gutter is
* a grid that has to be dragged before it is any use.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5,
})
const box = { x: 0, y: 0, w: 100, h: 100 }
describe('the grid catalogue', () => {
it('gives every grid a unique id', () => {
const ids = GRIDS.map(g => g.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('gives every grid at least one cell', () => {
for (const grid of GRIDS) expect(grid.cells.length, grid.id).toBeGreaterThan(0)
})
it('keeps every cell inside the unit block', () => {
for (const grid of GRIDS) {
for (const cell of grid.cells) {
expect(cell.x, grid.id).toBeGreaterThanOrEqual(0)
expect(cell.y, grid.id).toBeGreaterThanOrEqual(0)
expect(cell.x + cell.w, grid.id).toBeLessThanOrEqual(1.0001)
expect(cell.y + cell.h, grid.id).toBeLessThanOrEqual(1.0001)
expect(cell.w, grid.id).toBeGreaterThan(0)
expect(cell.h, grid.id).toBeGreaterThan(0)
}
}
})
/*
* Tiling grids cover the block exactly. The staggered and band arrangements
* deliberately do not — they are compositions with air around them — so they
* are checked for "no overlap" only, further down.
*/
it('tiles the block completely, for the grids that are meant to', () => {
const tiling = GRIDS.filter(g => !/stagger|band|filmstrip/.test(g.id))
for (const grid of tiling) {
const area = grid.cells.reduce((sum, c) => sum + c.w * c.h, 0)
expect(area, grid.id).toBeCloseTo(1, 5)
}
})
it('never overlaps two cells', () => {
for (const grid of GRIDS) {
for (let i = 0; i < grid.cells.length; i++) {
for (let j = i + 1; j < grid.cells.length; j++) {
const a = grid.cells[i]
const b = grid.cells[j]
const overlapX = Math.min(a.x + a.w, b.x + b.w) - Math.max(a.x, b.x)
const overlapY = Math.min(a.y + a.h, b.y + b.h) - Math.max(a.y, b.y)
const overlaps = overlapX > 0.0001 && overlapY > 0.0001
expect(overlaps, `${grid.id}: cell ${i} and ${j}`).toBe(false)
}
}
}
})
})
describe('gridElements', () => {
it('makes one empty photo frame per cell', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0)
expect(els).toHaveLength(4)
for (const el of els) {
expect(el.kind).toBe('photo')
expect(el.kind === 'photo' && el.photoId).toBeNull()
}
})
it('gives every frame its own id', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-9')!, box, 0)
expect(new Set(els.map(e => e.id)).size).toBe(els.length)
})
it('fills the box exactly when there is no gutter', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 0)
const left = Math.min(...els.map(e => e.frame.x))
const top = Math.min(...els.map(e => e.frame.y))
const right = Math.max(...els.map(e => e.frame.x + e.frame.w))
const bottom = Math.max(...els.map(e => e.frame.y + e.frame.h))
expect(left).toBeCloseTo(0, 6)
expect(top).toBeCloseTo(0, 6)
expect(right).toBeCloseTo(100, 6)
expect(bottom).toBeCloseTo(100, 6)
})
/*
* The gutter comes out of the cells, not out of the block. Added between them
* instead, a four-across grid would be three gutters wider than the box it
* was told to fill, and would hang off the safe area it was aligned to.
*/
it('keeps the block inside its box once a gutter is applied', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-6')!, box, 4)
for (const el of els) {
expect(el.frame.x).toBeGreaterThanOrEqual(-0.001)
expect(el.frame.y).toBeGreaterThanOrEqual(-0.001)
expect(el.frame.x + el.frame.w).toBeLessThanOrEqual(100.001)
expect(el.frame.y + el.frame.h).toBeLessThanOrEqual(100.001)
}
})
it('leaves the block edges flush and only splits the interior gaps', () => {
const gutter = 6
const els = gridElements(GRIDS.find(g => g.id === 'two-across')!, box, gutter)
const [a, b] = [...els].sort((x, y) => x.frame.x - y.frame.x)
// Outer edges untouched...
expect(a.frame.x).toBeCloseTo(0, 6)
expect(b.frame.x + b.frame.w).toBeCloseTo(100, 6)
// ...and exactly one gutter between them.
expect(b.frame.x - (a.frame.x + a.frame.w)).toBeCloseTo(gutter, 6)
})
it('offsets the whole block when the box does not start at the origin', () => {
const els = gridElements(GRIDS.find(g => g.id === 'single')!, { x: 20, y: 30, w: 50, h: 40 }, 0)
expect(els[0].frame).toMatchObject({ x: 20, y: 30, w: 50, h: 40 })
})
it('never produces a frame too small to see, even from a tiny box', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-12')!, { x: 0, y: 0, w: 8, h: 8 }, 6)
for (const el of els) {
expect(el.frame.w).toBeGreaterThanOrEqual(2)
expect(el.frame.h).toBeGreaterThanOrEqual(2)
}
})
it('carries the corner radius onto every frame', () => {
const els = gridElements(GRIDS.find(g => g.id === 'grid-4')!, box, 3, 5)
for (const el of els) expect(el.kind === 'photo' && el.radius).toBe(5)
})
it('produces frames for every grid in the catalogue without a bad number', () => {
for (const grid of GRIDS) {
for (const el of gridElements(grid, box, 3)) {
for (const v of [el.frame.x, el.frame.y, el.frame.w, el.frame.h]) {
expect(Number.isFinite(v), grid.id).toBe(true)
}
}
}
})
})
describe('defaultGridBox', () => {
it('spans both pages of a spread', () => {
const b = defaultGridBox(page, false)
expect(b.x + b.w).toBeCloseTo(page.pageWidth * 2 - b.x, 6)
expect(b.w).toBeGreaterThan(page.pageWidth)
})
it('stays on one page for a cover', () => {
const b = defaultGridBox(page, true)
expect(b.x + b.w).toBeLessThanOrEqual(page.pageWidth)
})
it('keeps clear of the safe area rather than landing on it', () => {
const b = defaultGridBox(page, true)
expect(b.x).toBeGreaterThanOrEqual(page.safe)
expect(b.y).toBeGreaterThanOrEqual(page.safe)
})
it('is symmetric, so a grid dropped into it looks centred', () => {
const b = defaultGridBox(page, false)
expect(b.x).toBeCloseTo(page.pageWidth * 2 - (b.x + b.w), 6)
expect(b.y).toBeCloseTo(page.pageHeight - (b.y + b.h), 6)
})
})
@@ -0,0 +1,126 @@
import { describe, it, expect, beforeEach } from 'vitest'
import type { BookDocument, BookElement } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { fireEvent, render } from '../../helpers/render'
import { StudioCanvas } from '../../../src/components/Studio/StudioCanvas'
import { useStudioStore } from '../../../src/store/studioStore'
/**
* A locked element (#1973).
*
* Locking used to switch the element's hit target off entirely, which made it a
* dead end: it could not be selected, so the inspector never showed it, so the
* unlock button was unreachable — the only way back was undo. Locked has to
* mean "cannot be moved", never "cannot be reached".
*/
const shape = (over: Partial<BookElement> = {}): BookElement => ({
id: 's1', kind: 'shape', frame: { x: 10, y: 10, w: 40, h: 30 },
rotation: 0, opacity: 1, locked: false,
shape: 'rect', fill: '#000000', gradient: 'none',
stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0,
...over,
} as BookElement)
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 })
function load(el: BookElement) {
useStudioStore.getState().load({
version: 1, title: 'T', page,
spreads: [{ id: 'sp1', role: 'inner', background: null, elements: [el], parked: [], entryId: null }],
} as BookDocument)
}
function draw() {
const spread = useStudioStore.getState().doc!.spreads[0]
return render(
<StudioCanvas
spread={spread}
spreadIndex={0}
page={page}
zoom={1}
pxPerMm={96 / 25.4}
bookView={false}
dropLabel=""
/>,
)
}
/** The hit target sits above the sheet, one per element. */
function hitTarget(container: HTMLElement): HTMLElement {
const sheet = container.querySelector('.st-sheet')!
return sheet.children[sheet.children.length - 1] as HTMLElement
}
beforeEach(() => {
useStudioStore.getState().load({
version: 1, title: 'T', page, spreads: [],
} as unknown as BookDocument)
})
describe('a locked element', () => {
it('can still be selected', () => {
load(shape({ locked: true }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
expect(useStudioStore.getState().selection).toEqual(['s1'])
})
/*
* The whole reason the previous behaviour was a trap: selection is what puts
* the element in the inspector, and the inspector is where the unlock is.
*/
it('can therefore be unlocked again', () => {
load(shape({ locked: true }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
const store = useStudioStore.getState()
store.commit(d => ({
...d,
spreads: d.spreads.map(sp => ({
...sp,
elements: sp.elements.map(e => (store.selection.includes(e.id) ? { ...e, locked: false } : e)),
})),
}))
expect(useStudioStore.getState().doc!.spreads[0].elements[0].locked).toBe(false)
})
it('does not move when dragged', () => {
load(shape({ locked: true }))
const { container } = draw()
const target = hitTarget(container)
fireEvent.pointerDown(target, { clientX: 100, clientY: 100 })
fireEvent.pointerMove(container.querySelector('.st-stage')!, { clientX: 300, clientY: 260 })
expect(useStudioStore.getState().doc!.spreads[0].elements[0].frame).toMatchObject({ x: 10, y: 10 })
})
it('offers no resize handles, since a drag on one would be refused', () => {
load(shape({ locked: true }))
const { container } = draw()
fireEvent.pointerDown(hitTarget(container))
expect(container.querySelectorAll('.st-handle')).toHaveLength(0)
// But it is selected, and the outline says so.
expect(container.querySelectorAll('.st-select')).toHaveLength(1)
})
it('leaves an unlocked element movable, with its handles', () => {
load(shape({ locked: false }))
const { container } = draw()
const stage = container.querySelector('.st-stage')!
fireEvent.pointerDown(hitTarget(container))
// The handles hide while a gesture is open — chrome that follows your hand
// around is noise — so the press has to be released before they appear.
fireEvent.pointerUp(stage)
expect(useStudioStore.getState().selection).toEqual(['s1'])
expect(container.querySelectorAll('.st-handle').length).toBeGreaterThan(0)
})
})
@@ -0,0 +1,208 @@
import { describe, it, expect } from 'vitest'
import { render } from '@testing-library/react'
import type { BookElement, BookSpread } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { SpreadView } from '../../../src/components/Studio/SpreadView'
import { tileView } from '../../../src/components/Studio/mapTiles'
/**
* What a route map shows, and what shape it is (#1973).
*
* The complaint this answers, in the words it arrived in: a trip that stayed in
* Berlin was drawn as the whole of Germany with two dots in the middle of it,
* and the line between stops was a zig-zag across country rather than the road
* anyone actually took. Both were decisions the element made for you.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210 })
const common = {
frame: { x: 0, y: 0, w: 120, h: 100 },
rotation: 0, opacity: 1, locked: false,
}
const typeset = { font: 'sans', color: '#111111', accent: '#111111', textScale: 1, weight: 700, stale: false }
const map = (over: Record<string, unknown> = {}): BookElement => ({
...common, ...typeset, id: 'm1', kind: 'map', style: 'minimal',
source: 'vector', tileUrl: '', attribution: '', zoom: null,
showLand: true, showRoute: true, showPins: true, showLabels: false,
countries: ['DE'],
// Two stops a few kilometres apart, both well inside one large country.
points: [
{ lat: 52.52, lng: 13.40, label: 'Berlin' },
{ lat: 52.50, lng: 13.45, label: 'Kreuzberg' },
],
path: [],
fitPadding: 0.18,
fitToCountries: false,
clip: 'rect',
...over,
} as BookElement)
function draw(el: BookElement) {
const spread: BookSpread = { id: 's', role: 'inner', background: null, elements: [el], parked: [], entryId: null }
return render(<SpreadView spread={spread} page={page} />)
}
/** How far apart the two pins are drawn, in the SVG's own units. */
function pinSpread(container: HTMLElement): number {
const circles = [...container.querySelectorAll('circle')]
const xs = circles.map(c => Number(c.getAttribute('cx')))
return Math.max(...xs) - Math.min(...xs)
}
describe('what the map is fitted to', () => {
/*
* The whole point. Fitted to the country, two stops five kilometres apart are
* two dots a hair's breadth from each other; fitted to the stops they are the
* width of the page, which is what a page about those two places wants.
*/
it('fits the stops, not the country they are in', () => {
const toStops = pinSpread(draw(map()).container)
const toCountry = pinSpread(draw(map({ fitToCountries: true })).container)
expect(toStops).toBeGreaterThan(toCountry * 10)
})
it('fits the country when asked, so the country page still works', () => {
const { container } = draw(map({ fitToCountries: true }))
// The outline fills the frame rather than sitting inside a box drawn round
// two stops in the middle of it.
expect(pinSpread(container)).toBeLessThan(20)
})
it('leaves more air around it the wider the padding', () => {
const tight = pinSpread(draw(map({ fitPadding: 0.02 })).container)
const wide = pinSpread(draw(map({ fitPadding: 1.5 })).container)
expect(tight).toBeGreaterThan(wide)
})
/* A single stop is a place, not an extent: it needs a window, not a country. */
it('gives one stop a window rather than dividing by zero', () => {
const { container } = draw(map({ points: [{ lat: 52.52, lng: 13.4, label: 'Berlin' }] }))
const circle = container.querySelector('circle')!
expect(Number(circle.getAttribute('cx'))).toBeGreaterThan(0)
expect(container.innerHTML).not.toContain('NaN')
})
it('falls back to the country when there is nothing else to fit', () => {
const { container } = draw(map({ points: [], path: [] }))
expect(container.innerHTML).not.toContain('NaN')
expect(container.querySelectorAll('path').length).toBeGreaterThan(0)
})
})
describe('the line the map draws', () => {
const berlinToHamburg: [number, number][] = [
[52.52, 13.40], [52.8, 12.9], [53.2, 12.0], [53.55, 10.0],
]
it('follows the travelled way when the trip knows it', () => {
const { container } = draw(map({ path: [berlinToHamburg] }))
const line = container.querySelector('polyline')!
// Four points on the way, not two stops joined by a ruler.
expect(line.getAttribute('points')!.trim().split(/\s+/)).toHaveLength(4)
})
it('joins the stops when it does not', () => {
const { container } = draw(map())
const line = container.querySelector('polyline')!
expect(line.getAttribute('points')!.trim().split(/\s+/)).toHaveLength(2)
})
/*
* Two tracks that do not join must stay two lines. Drawn as one, a trip with
* a flight in the middle grows a road across the sea.
*/
it('keeps separate tracks separate', () => {
const { container } = draw(map({
path: [berlinToHamburg, [[48.1, 11.6], [48.0, 11.0]] as [number, number][]],
}))
expect(container.querySelectorAll('polyline')).toHaveLength(2)
})
it('takes the travelled way into account when fitting', () => {
const near = draw(map()).container.innerHTML
const far = draw(map({ path: [berlinToHamburg] })).container.innerHTML
expect(near).not.toEqual(far)
})
})
describe('cutting the map to the land', () => {
it('cuts the imagery to the outline rather than to a box', () => {
const { container } = draw(map({
source: 'tiles',
tileUrl: 'https://tile.example/{z}/{x}/{y}.png',
clip: 'country',
}))
expect(container.querySelectorAll('clipPath')).toHaveLength(1)
expect(container.querySelectorAll('svg image').length).toBeGreaterThan(0)
// Nothing outside the stencil: the tiles are not also drawn as plain images.
expect(container.querySelectorAll('img')).toHaveLength(0)
})
it('draws a boxed map as a picture under the page, with no stencil', () => {
const { container } = draw(map({
source: 'tiles',
tileUrl: 'https://tile.example/{z}/{x}/{y}.png',
clip: 'rect',
}))
expect(container.querySelectorAll('clipPath')).toHaveLength(0)
expect(container.querySelectorAll('img').length).toBeGreaterThan(0)
})
/* A country-shaped hole cut in a city-sized view is a coloured rectangle. */
it('shows the whole country when cutting, whatever the fit said', () => {
const cut = pinSpread(draw(map({
source: 'tiles', tileUrl: 'https://tile.example/{z}/{x}/{y}.png',
clip: 'country', fitToCountries: false,
})).container)
expect(cut).toBeLessThan(20)
})
it('has nothing to cut against without a country, and stays a rectangle', () => {
const { container } = draw(map({
source: 'tiles', tileUrl: 'https://tile.example/{z}/{x}/{y}.png',
clip: 'country', countries: [],
}))
expect(container.querySelectorAll('clipPath')).toHaveLength(0)
})
})
describe('the tile grid', () => {
const frame = { w: 120, h: 100 }
const template = 'https://{s}.tiles.example/{z}/{x}/{y}.png'
const points = [{ lat: 52.52, lng: 13.40 }, { lat: 53.55, lng: 10.0 }]
it('covers the frame on both axes, with no paper showing at the edges', () => {
const view = tileView(points, frame, template, null)!
const right = view.originX + (Math.max(...view.tiles.map(t => t.x)) + 1) * view.size
const bottom = view.originY + (Math.max(...view.tiles.map(t => t.y)) + 1) * view.size
expect(view.originX).toBeLessThanOrEqual(0.001)
expect(view.originY).toBeLessThanOrEqual(0.001)
expect(right).toBeGreaterThanOrEqual(frame.w - 0.001)
expect(bottom).toBeGreaterThanOrEqual(frame.h - 0.001)
})
/*
* `{s}` left in the URL is not a placeholder, it is a hostname that does not
* resolve — the map draws as an empty frame with a route across it.
*/
it('substitutes the shard letter', () => {
const view = tileView(points, frame, template, null)!
expect(view.tiles.every(t => !t.url.includes('{s}'))).toBe(true)
expect(view.tiles[0].url).toMatch(/^https:\/\/[abc]\.tiles\.example\//)
})
it('gives the same tile the same host every time, so a reprint hits the cache', () => {
const a = tileView(points, frame, template, null)!
const b = tileView(points, frame, template, null)!
expect(a.tiles.map(t => t.url)).toEqual(b.tiles.map(t => t.url))
})
it('covers the extent it is given rather than a second fit of its own', () => {
const extent = { minLng: 5, maxLng: 15, minLat: 47, maxLat: 55 }
const view = tileView(points, frame, template, null, extent)!
const plain = tileView(points, frame, template, null)!
expect(view.zoom).not.toBe(plain.zoom)
})
})
+179
View File
@@ -0,0 +1,179 @@
import { describe, it, expect } from 'vitest'
import {
attributionFor, projectOntoTiles, staticMapUrl, tileView, toMercatorUnit,
} from '../../../src/components/Studio/mapTiles'
/**
* Real map imagery for the book (#1973).
*
* The vector map is the default and needs none of this. What is tested here is
* the opt-in: that a tiled map lands the route on the road it followed, that it
* cannot ask for a hundred tiles to fill a postcard, and that the credit the
* licence requires is never invented and never dropped.
*/
const OSM = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
const frame = { w: 150, h: 120 }
const iceland = [
{ lat: 64.1466, lng: -21.9426 },
{ lat: 65.6885, lng: -18.1262 },
]
describe('toMercatorUnit', () => {
it('puts the origin at the top left and the antimeridian at the edges', () => {
expect(toMercatorUnit(-180, 85.0511).x).toBeCloseTo(0, 5)
expect(toMercatorUnit(180, -85.0511).x).toBeCloseTo(1, 5)
expect(toMercatorUnit(0, 0)).toEqual({ x: 0.5, y: 0.5 })
})
it('clamps past the poles rather than returning infinity', () => {
const north = toMercatorUnit(0, 89.9)
expect(Number.isFinite(north.y)).toBe(true)
expect(north.y).toBeGreaterThanOrEqual(0)
})
})
describe('tileView', () => {
it('is null without a template or without points', () => {
expect(tileView(iceland, frame, '', null)).toBeNull()
expect(tileView([], frame, OSM, null)).toBeNull()
})
it('covers the route with real tile urls', () => {
const view = tileView(iceland, frame, OSM, null)!
expect(view.tiles.length).toBeGreaterThan(0)
for (const t of view.tiles) {
expect(t.url).toMatch(/^https:\/\/tile\.openstreetmap\.org\/\d+\/\d+\/\d+\.png$/)
}
})
/*
* A 12×12 grid is 144 requests for a picture 60mm across. The zoom backs off
* until the grid is a size worth printing.
*/
it('never asks for more tiles than a printed map can use', () => {
for (const z of [null, 19]) {
const view = tileView(iceland, frame, OSM, z)!
expect(view.tiles.length, `zoom ${z}`).toBeLessThanOrEqual(64)
}
})
it('zooms in on a short route and out on a long one', () => {
const city = tileView(
[{ lat: 64.14, lng: -21.94 }, { lat: 64.15, lng: -21.90 }], frame, OSM, null,
)!
const world = tileView(
[{ lat: 64.14, lng: -21.94 }, { lat: -33.86, lng: 151.2 }], frame, OSM, null,
)!
expect(city.zoom).toBeGreaterThan(world.zoom)
})
it('takes a fixed zoom when one is asked for', () => {
expect(tileView(iceland, frame, OSM, 5)!.zoom).toBe(5)
})
it('gives a single stop a window rather than dividing by zero', () => {
const view = tileView([{ lat: 64.14, lng: -21.94 }], frame, OSM, null)!
expect(view.tiles.length).toBeGreaterThan(0)
expect(Number.isFinite(view.size)).toBe(true)
expect(view.size).toBeGreaterThan(0)
})
it('drops rows past the poles, where no tile exists', () => {
const view = tileView(
[{ lat: 85, lng: 0 }, { lat: 84.9, lng: 0.1 }], frame, OSM, 3,
)!
const scale = 2 ** view.zoom
for (const t of view.tiles) {
const absoluteY = view.tileY0 + t.y
expect(absoluteY).toBeGreaterThanOrEqual(0)
expect(absoluteY).toBeLessThan(scale)
}
})
it('wraps longitude, so a route across the antimeridian keeps its tiles', () => {
const view = tileView(
[{ lat: 0, lng: 179 }, { lat: 0, lng: -179 }], frame, OSM, 3,
)!
for (const t of view.tiles) {
const x = Number(/\/(\d+)\/(\d+)\/(\d+)\.png$/.exec(t.url)![2])
expect(x).toBeGreaterThanOrEqual(0)
expect(x).toBeLessThan(2 ** view.zoom)
}
})
})
describe('projectOntoTiles', () => {
/*
* The reason the grid origin is exposed at all: the vector map fits itself to
* country outlines, so a route placed by that fit sits next to the road
* rather than on it — an error that only shows up once it is printed.
*/
it('places the route inside the frame it was fitted to', () => {
const view = tileView(iceland, frame, OSM, null)!
for (const p of iceland) {
const at = projectOntoTiles(view, p.lng, p.lat)
expect(at.x).toBeGreaterThan(-1)
expect(at.x).toBeLessThan(frame.w + 1)
expect(at.y).toBeGreaterThan(-1)
expect(at.y).toBeLessThan(frame.h + 1)
}
})
it('keeps west of east and north above south', () => {
const view = tileView(iceland, frame, OSM, null)!
const west = projectOntoTiles(view, -21.94, 64.14)
const east = projectOntoTiles(view, -18.12, 65.68)
expect(west.x).toBeLessThan(east.x)
expect(west.y).toBeGreaterThan(east.y)
})
})
describe('attribution', () => {
it('credits the source a template comes from', () => {
expect(attributionFor(OSM)).toContain('OpenStreetMap')
expect(attributionFor('https://api.mapbox.com/x/{z}/{x}/{y}')).toContain('Mapbox')
expect(attributionFor('https://api.maptiler.com/x')).toContain('MapTiler')
})
it('credits OpenStreetMap for a self-hosted mirror of it', () => {
expect(attributionFor('https://tiles.example.org/openstreetmap/{z}/{x}/{y}.png'))
.toContain('OpenStreetMap')
})
/* A wrong credit is worse than none. */
it('invents nothing for a source it does not recognise', () => {
expect(attributionFor('https://tiles.internal/{z}/{x}/{y}.png')).toBe('')
})
})
describe('staticMapUrl', () => {
it('is null without a token, since the request would only 401', () => {
expect(staticMapUrl({ points: iceland, style: '', token: '', width: 600, height: 400 })).toBeNull()
})
it('is null without points to frame', () => {
expect(staticMapUrl({ points: [], style: '', token: 'tok', width: 600, height: 400 })).toBeNull()
})
it('asks for the route bounding box at twice the size, for print', () => {
const url = staticMapUrl({ points: iceland, style: '', token: 'tok', width: 600, height: 400 })!
expect(url).toContain('@2x')
expect(url).toContain('-21.94260,64.14660,-18.12620,65.68850')
})
it('takes a mapbox:// style and passes the plain path', () => {
const url = staticMapUrl({
points: iceland, style: 'mapbox://styles/mapbox/outdoors-v12', token: 'tok',
width: 600, height: 400,
})!
expect(url).toContain('/styles/v1/mapbox/outdoors-v12/static/')
expect(url).not.toContain('mapbox://')
})
it('stays inside the size the API accepts', () => {
const url = staticMapUrl({ points: iceland, style: '', token: 'tok', width: 9000, height: 9000 })!
expect(url).toContain('1280x1280')
})
})
+162
View File
@@ -0,0 +1,162 @@
import { describe, it, expect } from 'vitest'
import type { BookPageSetup, BookSpread } from '@trek/shared'
import { bookPageSetupSchema, normalizeBookDocument } from '@trek/shared'
import { render } from '../../helpers/render'
import { SpreadView } from '../../../src/components/Studio/SpreadView'
import { PAGE_MAX_MM, PAGE_MIN_MM, PAGE_PRESETS, clampPageSize } from '../../../src/components/Studio/pagePresets'
import { relayoutSpread, type AutoInput } from '../../../src/components/Studio/autoLayout'
/**
* A free trim size, page numbers, and laying out again (#1973).
*/
type PageOver = Partial<Omit<BookPageSetup, 'pageNumbers'>> & {
pageNumbers?: Partial<BookPageSetup['pageNumbers']>
}
const page = (over: PageOver = {}): BookPageSetup =>
bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, ...over })
const spread = (over: Partial<BookSpread> = {}): BookSpread => ({
id: 's1', role: 'inner', background: null, elements: [], parked: [], entryId: null, ...over,
})
describe('a custom trim size', () => {
it('is offered as a preset of its own', () => {
expect(PAGE_PRESETS.custom).toBeTruthy()
expect(PAGE_PRESETS.custom.id).toBe('custom')
})
it('is what the document contract already allowed', () => {
const parsed = page({ preset: 'custom', pageWidth: 240, pageHeight: 180 })
expect(parsed.preset).toBe('custom')
expect(parsed.pageWidth).toBe(240)
})
/*
* The floor is a passport-sized book and the ceiling is what a large press
* takes — past either, the editor is scaling to a smear rather than showing
* a page.
*/
it('holds a typed size inside what a press can print', () => {
expect(clampPageSize(10)).toBe(PAGE_MIN_MM)
expect(clampPageSize(9000)).toBe(PAGE_MAX_MM)
expect(clampPageSize(240)).toBe(240)
})
it('rounds to a tenth of a millimetre, not to whatever was typed', () => {
expect(clampPageSize(210.06)).toBe(210.1)
})
it('refuses nonsense rather than passing NaN into the document', () => {
expect(clampPageSize(Number.NaN)).toBe(PAGE_MIN_MM)
})
})
describe('page numbers', () => {
it('are off until asked for', () => {
expect(page().pageNumbers.show).toBe(false)
const { container } = render(<SpreadView spread={spread()} page={page()} spreadIndex={1} />)
expect(container.textContent).toBe('')
})
it('number both pages of a spread, left then right', () => {
const { container } = render(
<SpreadView spread={spread()} page={page({ pageNumbers: { show: true } })} spreadIndex={1} />,
)
expect(container.textContent).toBe('23')
})
/*
* The first inner spread opens on `startAt`, and the cover is a separate
* sheet — which is why the index has to be offset rather than used raw.
*/
it('advance by two for each spread further into the book', () => {
const p = page({ pageNumbers: { show: true } })
const third = render(<SpreadView spread={spread()} page={p} spreadIndex={3} />)
expect(third.container.textContent).toBe('67')
})
it('start where the book says, since a binder may or may not count the cover', () => {
const p = page({ pageNumbers: { show: true, startAt: 1 } })
const { container } = render(<SpreadView spread={spread()} page={p} spreadIndex={1} />)
expect(container.textContent).toBe('12')
})
/* A folio on a cover is a mistake in every book ever bound. */
it('never appear on the cover or the back cover', () => {
const p = page({ pageNumbers: { show: true } })
for (const role of ['cover', 'back'] as const) {
const { container } = render(<SpreadView spread={spread({ role })} page={p} spreadIndex={0} />)
expect(container.textContent, role).toBe('')
}
})
it('sit against the cut edge on outer, and in the gutter on inner', () => {
const outer = render(
<SpreadView spread={spread()} page={page({ pageNumbers: { show: true, position: 'outer' } })} spreadIndex={1} />,
)
const inner = render(
<SpreadView spread={spread()} page={page({ pageNumbers: { show: true, position: 'inner' } })} spreadIndex={1} />,
)
const leftOf = (c: HTMLElement) =>
parseFloat(((c.firstElementChild!.children[0]) as HTMLElement).style.left)
// Outer puts the left page's number at the margin; inner pushes it in.
expect(leftOf(outer.container)).toBeLessThan(leftOf(inner.container))
})
it('survive a round trip through the document contract', () => {
const doc = normalizeBookDocument({
version: 1, title: 'T',
page: { preset: 'custom', pageWidth: 240, pageHeight: 180, pageNumbers: { show: true, startAt: 5 } },
spreads: [],
})
expect(doc.page.pageNumbers.show).toBe(true)
expect(doc.page.pageNumbers.startAt).toBe(5)
expect(doc.page.pageWidth).toBe(240)
})
it('default in for a document written before they existed', () => {
const doc = normalizeBookDocument({
version: 1, title: 'T',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210 },
spreads: [],
})
expect(doc.page.pageNumbers.show).toBe(false)
expect(doc.page.pageNumbers.position).toBe('outer')
})
})
describe('laying a spread out again', () => {
const input: AutoInput = {
locale: 'en', title: 'T', subtitle: null, coverPhotoId: null,
entries: [{ id: 7, title: 'A day', story: 'Words.', location: 'Here', date: '2026-06-02', photos: [] }],
page: page(), stats: null, stationsLabel: 'Stations', dayLabel: 'DAY', summaryLabel: 'Trip summary', countriesLabel: 'Countries',
}
it('rebuilds the spread from the entry it came from', () => {
const next = relayoutSpread(spread({ entryId: 7, elements: [] }), input)
expect(next).not.toBeNull()
expect(next!.entryId).toBe(7)
expect(next!.elements.length).toBeGreaterThan(0)
})
/*
* Keeping the id is what makes it read as the same page rearranged: the rail
* does not scroll and the selection does not jump to a different card.
*/
it('keeps the spread id, so the rail does not jump', () => {
const next = relayoutSpread(spread({ id: 'keep-me', entryId: 7 }), input)
expect(next!.id).toBe('keep-me')
})
it('refuses a spread that did not come from an entry', () => {
expect(relayoutSpread(spread({ entryId: null }), input)).toBeNull()
expect(relayoutSpread(spread({ role: 'cover', entryId: 7 }), input)).toBeNull()
expect(relayoutSpread(spread({ role: 'back', entryId: 7 }), input)).toBeNull()
})
it('refuses when the entry is gone from the journey', () => {
expect(relayoutSpread(spread({ entryId: 999 }), input)).toBeNull()
})
})
@@ -0,0 +1,285 @@
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'
import { printSheets } from '../../../src/components/Studio/printSheets'
/**
* The print view (#1973).
*
* The browser is what turns this into a PDF, so the things worth pinning are
* the ones a browser silently gets wrong: a `@page` rule that does not match
* the sheets, a document printed before its photographs arrived, and a save
* button offered while neither has happened.
*/
const labels = { save: 'Save as PDF', close: 'Close', count: '17 sheets', preparing: 'Preparing' }
function open(over: Partial<Parameters<typeof printSheets>[0]> = {}) {
return printSheets({
html: '<div class="bx-sheet"></div>',
sheetWidth: 224,
sheetHeight: 224,
title: 'Iceland',
labels,
...over,
})
}
const overlay = () => document.getElementById('bx-overlay')
const frame = () => overlay()!.querySelector('iframe')!
const saveButton = () => overlay()!.querySelector<HTMLButtonElement>('#bx-save')!
beforeEach(() => {
document.body.innerHTML = ''
document.head.innerHTML = ''
})
afterEach(() => {
overlay()?.remove()
})
describe('the document', () => {
/*
* The rule the printer measures. A mismatch here scales the whole book to fit
* whatever the browser thought the paper was — and looks perfect on screen.
*/
it('sets one page rule to the sheet size, with no printer margin', () => {
open({ sheetWidth: 224, sheetHeight: 303 })
expect(frame().srcdoc).toContain('@page { size: 224mm 303mm; margin: 0; }')
})
it('puts the sheets in the body', () => {
open({ html: '<div class="bx-sheet">page one</div>' })
expect(frame().srcdoc).toContain('<body><div class="bx-sheet">page one</div></body>')
})
/*
* The sheets are the editor's own components, so they need the editor's own
* CSS — including the @font-face rules. Without them a book proofread in one
* typeface prints in a substitute, which is the whole reason the faces are
* bundled in the first place.
*/
it('carries the app stylesheets across, inline and linked alike', () => {
const style = document.createElement('style')
style.textContent = '.st-x { color: red }'
document.head.appendChild(style)
const link = document.createElement('link')
link.rel = 'stylesheet'
link.href = 'http://localhost/assets/app.css'
document.head.appendChild(link)
open()
const html = frame().srcdoc
expect(html).toContain('.st-x { color: red }')
expect(html).toContain('<link rel="stylesheet" href="http://localhost/assets/app.css">')
})
/*
* Photographs are the point of the thing. Browsers drop backgrounds and
* colour-managed fills by default, which is right for a web page and would
* print a photo book as an outline of itself.
*/
it('asks the browser not to strip colour', () => {
open()
expect(frame().srcdoc).toContain('print-color-adjust: exact')
})
it('escapes the title rather than letting it into the markup', () => {
open({ title: '<script>x</script> & co' })
const html = frame().srcdoc
expect(html).toContain('&lt;script&gt;x&lt;/script&gt; &amp; co')
expect(html).not.toContain('<script>x</script>')
})
/* Nothing inside the frame runs, so printing is driven from out here. */
it('withholds allow-scripts from the frame', () => {
open()
expect(frame().getAttribute('sandbox')).toBe('allow-same-origin allow-modals')
})
})
describe('a document of two sheet sizes', () => {
/*
* Spread mode mixes them: covers are one page, everything between is two.
* Before the named rule every sheet was made as wide as the widest, which put
* a cover in the middle of a spread-sized page with white either side.
*/
it('gives the one-page sheets a page box of their own', () => {
open({ sheetWidth: 426, sheetHeight: 224, singleWidth: 224, singleHeight: 224 })
const html = frame().srcdoc
expect(html).toContain('@page { size: 426mm 224mm; margin: 0; }')
expect(html).toContain('@page single { size: 224mm 224mm; margin: 0; }')
expect(html).toContain('.bx-sheet.is-single { page: single; }')
})
/* Page mode has one size, so a second rule would be noise. */
it('writes no second rule when every sheet is the same size', () => {
open({ sheetWidth: 224, sheetHeight: 224, singleWidth: 224, singleHeight: 224 })
expect(frame().srcdoc).not.toContain('@page single')
})
it('writes none when no single size was given at all', () => {
open({ sheetWidth: 224, sheetHeight: 224 })
expect(frame().srcdoc).not.toContain('@page single')
})
})
describe('fitting the preview', () => {
/*
* A spread sheet is over 400 mm across — wider than any preview pane at 1:1 —
* and the book used to run off the side, to be read by scrolling sideways.
* Only on screen: the print itself stays at full size, or the press gets a
* shrunken book.
*/
it('scales the preview down to the pane, and only the preview', () => {
open()
const html = frame().srcdoc
expect(html).toContain('@media screen { .bx-book { zoom: var(--bx-fit, 1); } }')
// Nothing in the print block touches the scale.
const printBlock = html.slice(html.indexOf('@media print'))
expect(printBlock).not.toContain('zoom')
})
it('sets the factor from the pane width once the frame is up', () => {
open({ sheetWidth: 426 })
const iframe = frame()
const root = { style: { setProperty: vi.fn() } }
Object.defineProperty(iframe, 'clientWidth', { value: 800, configurable: true })
Object.defineProperty(iframe, 'contentDocument', {
value: { documentElement: root, images: [], fonts: { ready: Promise.resolve() } },
configurable: true,
})
Object.defineProperty(iframe, 'contentWindow', { value: { print: vi.fn() }, configurable: true })
iframe.dispatchEvent(new Event('load'))
// (800 - 32) / (426 mm × 96/25.4) ≈ 0.477
const [name, value] = root.style.setProperty.mock.calls[0]
expect(name).toBe('--bx-fit')
expect(Number(value)).toBeCloseTo(0.477, 2)
})
/* A book smaller than the pane is shown at its real size, not blown up. */
it('never enlarges', () => {
open({ sheetWidth: 100 })
const iframe = frame()
const root = { style: { setProperty: vi.fn() } }
Object.defineProperty(iframe, 'clientWidth', { value: 1600, configurable: true })
Object.defineProperty(iframe, 'contentDocument', {
value: { documentElement: root, images: [], fonts: { ready: Promise.resolve() } },
configurable: true,
})
Object.defineProperty(iframe, 'contentWindow', { value: { print: vi.fn() }, configurable: true })
iframe.dispatchEvent(new Event('load'))
expect(root.style.setProperty).toHaveBeenCalledWith('--bx-fit', '1')
})
it('stops listening for resizes when it closes', () => {
const remove = vi.spyOn(window, 'removeEventListener')
const close = open()
close()
expect(remove).toHaveBeenCalledWith('resize', expect.any(Function))
remove.mockRestore()
})
})
describe('the save button', () => {
it('starts disabled, because nothing has loaded yet', () => {
open()
expect(saveButton().disabled).toBe(true)
expect(saveButton().textContent).toBe('Preparing')
})
/*
* Enabled only once the images and the fonts are in. Printing early is how a
* book comes back from the press with empty rectangles where the photographs
* were.
*/
it('waits for the pictures before offering to save', async () => {
open()
const iframe = frame()
let resolveImage: () => void = () => {}
const pending = new Promise<void>(r => { resolveImage = r })
const image = {
complete: false,
addEventListener: (event: string, fn: () => void) => {
if (event === 'load') void pending.then(fn)
},
}
Object.defineProperty(iframe, 'contentDocument', {
value: { images: [image], fonts: { ready: Promise.resolve() } },
configurable: true,
})
Object.defineProperty(iframe, 'contentWindow', { value: { print: vi.fn() }, configurable: true })
iframe.dispatchEvent(new Event('load'))
await Promise.resolve()
expect(saveButton().disabled).toBe(true)
resolveImage()
await new Promise(r => setTimeout(r, 0))
expect(saveButton().disabled).toBe(false)
expect(saveButton().textContent).toBe('Save as PDF')
})
/* One picture that will not load must not hold the whole book hostage. */
it('gives up on a broken image rather than waiting forever', async () => {
open()
const iframe = frame()
const image = {
complete: false,
addEventListener: (event: string, fn: () => void) => {
if (event === 'error') setTimeout(fn, 0)
},
}
Object.defineProperty(iframe, 'contentDocument', {
value: { images: [image], fonts: { ready: Promise.resolve() } },
configurable: true,
})
Object.defineProperty(iframe, 'contentWindow', { value: { print: vi.fn() }, configurable: true })
iframe.dispatchEvent(new Event('load'))
await new Promise(r => setTimeout(r, 5))
expect(saveButton().disabled).toBe(false)
})
it('prints the frame, not the page around it', async () => {
open()
const iframe = frame()
const print = vi.fn()
Object.defineProperty(iframe, 'contentDocument', {
value: { images: [], fonts: { ready: Promise.resolve() } },
configurable: true,
})
Object.defineProperty(iframe, 'contentWindow', { value: { print }, configurable: true })
iframe.dispatchEvent(new Event('load'))
await new Promise(r => setTimeout(r, 0))
saveButton().click()
expect(print).toHaveBeenCalled()
})
})
describe('closing', () => {
it('takes the overlay with it', () => {
open()
expect(overlay()).not.toBeNull()
overlay()!.querySelector<HTMLButtonElement>('#bx-close')!.click()
expect(overlay()).toBeNull()
})
it('closes on a click outside the card, but not inside it', () => {
open()
overlay()!.querySelector<HTMLElement>('.bx-card')!.click()
expect(overlay()).not.toBeNull()
overlay()!.click()
expect(overlay()).toBeNull()
})
it('hands back a way to close it', () => {
const close = open()
close()
expect(overlay()).toBeNull()
})
})
@@ -0,0 +1,62 @@
import { describe, it, expect } from 'vitest'
import type { BookElement } from '@trek/shared'
import { bookElementSchema } from '@trek/shared'
import { render } from '../../helpers/render'
import { TravelElementView } from '../../../src/components/Studio/TravelElements'
/** The renderer positions from the outside; a bare box is enough here. */
const box = { position: 'absolute' as const, width: '100mm', height: '80mm' }
/**
* What the travel elements are drawn in (#1973).
*
* Ink by default rather than the app's orange: a book is printed, and a colour
* that arrives without being chosen turns every element into the same accent
* whether the book wants one or not. The one exception is the dark map, where
* ink would be a black line on a near-black country.
*/
const mapEl = (over: Record<string, unknown> = {}): BookElement =>
bookElementSchema.parse({
id: 'm1', kind: 'map', frame: { x: 0, y: 0, w: 100, h: 80 },
points: [
{ lat: 64.1, lng: -21.9, label: 'A' },
{ lat: 65.7, lng: -18.1, label: 'B' },
],
countries: ['IS'],
showRoute: true,
showPins: true,
...over,
})
const strokes = (container: HTMLElement) =>
[...container.querySelectorAll('polyline')].map(p => p.getAttribute('stroke'))
describe('the default', () => {
it('is ink, not the app accent', () => {
const el = mapEl()
expect((el as { accent: string }).accent).toBe('#111111')
})
it('draws the route in ink on a light map', () => {
const { container } = render(<TravelElementView el={mapEl({ style: 'minimal' })} frameStyle={box} />)
expect(strokes(container)).toContain('#111111')
})
})
describe('the dark map', () => {
/* Ink on near-black is a line nobody can see. */
it('draws the route pale instead', () => {
const { container } = render(<TravelElementView el={mapEl({ style: 'dark' })} frameStyle={box} />)
expect(strokes(container)[0]).toBe('#f2efe9')
})
/*
* Only the default is overridden. A colour somebody picked is theirs, even
* when it is a poor choice on a dark map — that is their call to make.
*/
it('leaves a chosen colour alone', () => {
const { container } = render(<TravelElementView el={mapEl({ style: 'dark', accent: '#c2410c' })} frameStyle={box} />)
expect(strokes(container)[0]).toBe('#c2410c')
})
})
+319
View File
@@ -0,0 +1,319 @@
import { describe, it, expect } from 'vitest'
import { BOOK_SHAPES } from '@trek/shared'
import {
FRAME_SHAPES, HOLED_SHAPES, SHAPE_GROUPS, SHAPE_PATHS, scalePath, unitPath,
} from '../../../src/components/Studio/shapes'
/**
* The shape library (#1973).
*
* The invariant worth guarding is the boring one: `scalePath` scales numbers by
* position, alternating x and y from the start of the path. That only works if
* every path is written in the absolute M/L/C/Q/Z subset with an even count of
* numbers per command. Break that in one hand-drawn path and *that shape alone*
* comes out sheared — which is exactly the kind of bug nobody notices until it
* is printed.
*/
/** Commands and their coordinate counts. Anything else is out of the subset. */
const COMMANDS: Record<string, number> = { M: 2, L: 2, C: 6, Q: 4, Z: 0 }
/**
* Where the outline actually goes.
*
* Measuring the raw numbers would measure the control points, which for a curve
* sit outside the shape by design — a quadratic never reaches its control, and a
* cubic's two controls are usually well past the ink. Sampling the curves gives
* the box the shape really occupies, which is the thing "normalised to 0..100"
* is a claim about.
*/
function drawnBounds(path: string): { minX: number; minY: number; maxX: number; maxY: number } {
const nums = (path.match(/-?\d*\.?\d+/g) ?? []).map(Number)
const tokens = path.match(/[MLCQZ]/g) ?? []
let i = 0
let cx = 0
let cy = 0
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity
const hit = (x: number, y: number) => {
if (x < minX) minX = x
if (x > maxX) maxX = x
if (y < minY) minY = y
if (y > maxY) maxY = y
}
for (const cmd of tokens) {
if (cmd === 'Z') continue
if (cmd === 'M' || cmd === 'L') {
cx = nums[i++]; cy = nums[i++]
hit(cx, cy)
continue
}
if (cmd === 'Q') {
const [qx, qy, x, y] = [nums[i++], nums[i++], nums[i++], nums[i++]]
for (let t = 0; t <= 1.0001; t += 0.05) {
const u = 1 - t
hit(u * u * cx + 2 * u * t * qx + t * t * x, u * u * cy + 2 * u * t * qy + t * t * y)
}
cx = x; cy = y
continue
}
// C
const [c1x, c1y, c2x, c2y, x, y] = [nums[i++], nums[i++], nums[i++], nums[i++], nums[i++], nums[i++]]
for (let t = 0; t <= 1.0001; t += 0.05) {
const u = 1 - t
hit(
u * u * u * cx + 3 * u * u * t * c1x + 3 * u * t * t * c2x + t * t * t * x,
u * u * u * cy + 3 * u * u * t * c1y + 3 * u * t * t * c2y + t * t * t * y,
)
}
cx = x; cy = y
}
return { minX, minY, maxX, maxY }
}
/** Split a path into its subpaths, one per `M`. */
function subpaths(path: string): string[] {
return path.split('M').filter(Boolean).map(p => `M${p}`)
}
/**
* Twice the signed area of a subpath, sampling curves so a bubble's rounded
* body is measured as the ring it draws rather than as its corner points.
* Only the sign matters here — it is the winding direction.
*/
function signedArea(path: string): number {
const { points } = flatten(path)
let a = 0
for (let i = 0, j = points.length - 1; i < points.length; j = i++) {
a += points[j][0] * points[i][1] - points[i][0] * points[j][1]
}
return a / 2
}
/** The subpath as a polyline, curves sampled. */
function flatten(path: string): { points: [number, number][] } {
const nums = (path.match(/-?\d*\.?\d+/g) ?? []).map(Number)
const cmds = path.match(/[MLCQZ]/g) ?? []
const points: [number, number][] = []
let i = 0
let cx = 0
let cy = 0
for (const cmd of cmds) {
if (cmd === 'Z') continue
if (cmd === 'M' || cmd === 'L') {
cx = nums[i++]; cy = nums[i++]
points.push([cx, cy])
continue
}
if (cmd === 'Q') {
const [qx, qy, x, y] = [nums[i++], nums[i++], nums[i++], nums[i++]]
for (let t = 0.25; t <= 1.0001; t += 0.25) {
const u = 1 - t
points.push([u * u * cx + 2 * u * t * qx + t * t * x, u * u * cy + 2 * u * t * qy + t * t * y])
}
cx = x; cy = y
continue
}
const [ax, ay, bx, by, x, y] = [nums[i++], nums[i++], nums[i++], nums[i++], nums[i++], nums[i++]]
for (let t = 0.25; t <= 1.0001; t += 0.25) {
const u = 1 - t
points.push([
u * u * u * cx + 3 * u * u * t * ax + 3 * u * t * t * bx + t * t * t * x,
u * u * u * cy + 3 * u * u * t * ay + 3 * u * t * t * by + t * t * t * y,
])
}
cx = x; cy = y
}
return { points }
}
function commandsOf(path: string): { cmd: string; nums: number }[] {
const out: { cmd: string; nums: number }[] = []
const re = /([MLCQZ])([^MLCQZ]*)/g
let m: RegExpExecArray | null
while ((m = re.exec(path))) {
const nums = (m[2].match(/-?\d*\.?\d+/g) ?? []).length
out.push({ cmd: m[1], nums })
}
return out
}
describe('SHAPE_PATHS', () => {
it('has a path for every shape the document schema allows', () => {
for (const shape of BOOK_SHAPES) {
expect(SHAPE_PATHS[shape], shape).toBeTruthy()
}
})
it('uses only absolute M, L, C, Q and Z', () => {
for (const shape of BOOK_SHAPES) {
const path = SHAPE_PATHS[shape]
expect(path, shape).not.toMatch(/[mlcqzahsvtHSVTA]/)
expect(path, shape).toMatch(/^M/)
}
})
it('gives every command the number of coordinates it takes', () => {
for (const shape of BOOK_SHAPES) {
for (const { cmd, nums } of commandsOf(SHAPE_PATHS[shape])) {
expect(nums, `${shape}: ${cmd}`).toBe(COMMANDS[cmd])
}
}
})
/*
* The one that makes scalePath valid: numbers alternate x, y across the whole
* path, so an odd total anywhere would swap the axes for everything after it.
*/
it('holds an even number of coordinates, so x/y alternation survives the whole path', () => {
for (const shape of BOOK_SHAPES) {
const total = (SHAPE_PATHS[shape].match(/-?\d*\.?\d+/g) ?? []).length
expect(total % 2, shape).toBe(0)
}
})
it('closes every subpath', () => {
for (const shape of BOOK_SHAPES) {
const opens = (SHAPE_PATHS[shape].match(/M/g) ?? []).length
const closes = (SHAPE_PATHS[shape].match(/Z/g) ?? []).length
expect(closes, shape).toBe(opens)
}
})
/*
* The invariant the editor depends on: selection outlines, resize handles and
* snap targets are all drawn from the element's frame, so a shape that does
* not fill its frame cannot be aligned to anything visible. `normalise()` in
* shapes.ts makes this true by construction; this checks it stayed true.
*
* Every offender is collected rather than failing on the first — a change to
* the geometry usually breaks a whole family of shapes at once, and one name
* at a time is a slow way to find that out.
*/
it('fills its 0..100 box exactly, on both axes', () => {
const off: string[] = []
for (const shape of BOOK_SHAPES) {
const { minX, minY, maxX, maxY } = drawnBounds(SHAPE_PATHS[shape])
const flat = shape === 'line' // a rule genuinely has no height
const wrong = Math.abs(minX) > 0.3 || Math.abs(maxX - 100) > 0.3
|| (!flat && (Math.abs(minY) > 0.3 || Math.abs(maxY - 100) > 0.3))
if (wrong) {
off.push(`${shape}: x ${minX.toFixed(1)}..${maxX.toFixed(1)} y ${minY.toFixed(1)}..${maxY.toFixed(1)}`)
}
}
expect(off).toEqual([])
})
})
describe('scalePath', () => {
it('leaves a path alone at 100 by 100', () => {
expect(scalePath('M0 0L100 0L100 100Z', 100, 100)).toBe('M0 0L100 0L100 100Z')
})
it('scales x and y independently', () => {
expect(scalePath('M0 0L100 50Z', 50, 20)).toBe('M0 0L50 10Z')
})
it('keeps alternating across commands with different arities', () => {
// C takes six numbers: three x/y pairs. The pair after it must still start
// on x.
expect(scalePath('M0 0C10 20 30 40 50 60L100 100Z', 10, 100))
.toBe('M0 0C1 20 3 40 5 60L10 100Z')
})
it('carries negative coordinates through with their sign', () => {
expect(scalePath('M-10 -20L100 100Z', 50, 50)).toBe('M-5 -10L50 50Z')
})
it('rounds to three decimals rather than emitting float noise', () => {
const out = scalePath('M33 33Z', 7, 7)
expect(out).toBe('M2.31 2.31Z')
expect(out).not.toMatch(/\d{5}/)
})
it('produces a drawable path for every shape at an extreme aspect ratio', () => {
for (const shape of BOOK_SHAPES) {
const wide = scalePath(SHAPE_PATHS[shape], 300, 4)
expect(wide, shape).not.toMatch(/NaN|Infinity|undefined/)
const nums = (wide.match(/-?\d*\.?\d+/g) ?? []).map(Number)
expect(nums.every(Number.isFinite), shape).toBe(true)
}
})
})
describe('unitPath', () => {
it('maps the 0..100 box onto 0..1 for an objectBoundingBox clip', () => {
expect(unitPath('M0 0L100 50Z')).toBe('M0 0L1 0.5Z')
})
it('scales both axes by the same factor, unlike scalePath', () => {
expect(unitPath('M25 75Z')).toBe('M0.25 0.75Z')
})
it('keeps every shape within the unit box, allowing for the blobs slack', () => {
for (const shape of BOOK_SHAPES) {
const { minX, minY, maxX, maxY } = drawnBounds(unitPath(SHAPE_PATHS[shape]))
expect(Math.min(minX, minY), shape).toBeGreaterThanOrEqual(-0.06)
expect(Math.max(maxX, maxY), shape).toBeLessThanOrEqual(1.06)
}
})
})
describe('the panel groupings', () => {
it('offers every shape in exactly one group', () => {
const grouped = SHAPE_GROUPS.flatMap(g => g.shapes)
expect(new Set(grouped).size).toBe(grouped.length)
})
it('groups every shape the schema allows, so none is unreachable in the UI', () => {
const grouped = new Set(SHAPE_GROUPS.flatMap(g => g.shapes))
// 'line' is offered as its own tile in the Lines section rather than as a
// shape you pick from a grid, since a hairline is invisible at tile size.
const missing = BOOK_SHAPES.filter(s => s !== 'line' && !grouped.has(s))
expect(missing).toEqual([])
})
it('only offers frames that actually exist as shapes', () => {
for (const shape of FRAME_SHAPES) {
expect(SHAPE_PATHS[shape], shape).toBeTruthy()
}
})
it('keeps thin and hollow shapes out of the frame list, since a photo cut to one is unreadable', () => {
for (const thin of ['line', 'gear', 'bubble-think', 'sparkle', 'cross', 'plus'] as const) {
expect(FRAME_SHAPES, thin).not.toContain(thin)
}
})
/*
* The bug this catches: a speech bubble's tail was listed anticlockwise while
* its body ran clockwise, and under the default non-zero fill rule the two
* cancelled where they met — the tail came out as a wedge of hole punched
* through the bubble. Nothing about the geometry was wrong; only the order
* the points were written in.
*
* So every subpath of a shape has to wind the same way as the first, except
* where a hole is the point.
*/
it('winds every subpath the same way, so overlapping parts join rather than cancel', () => {
const wrong: string[] = []
for (const shape of BOOK_SHAPES) {
if (HOLED_SHAPES.has(shape)) continue
const windings = subpaths(SHAPE_PATHS[shape]).map(signedArea)
if (windings.length < 2) continue
const first = Math.sign(windings[0])
if (windings.some(a => Math.sign(a) !== first)) {
wrong.push(`${shape}: ${windings.map(a => a.toFixed(0)).join(', ')}`)
}
}
expect(wrong).toEqual([])
})
it('marks only the shapes drawn as an outer ring plus an inner hole', () => {
for (const shape of HOLED_SHAPES) {
const subpaths = (SHAPE_PATHS[shape].match(/M/g) ?? []).length
expect(subpaths, shape).toBeGreaterThan(1)
}
})
})
+156
View File
@@ -0,0 +1,156 @@
import { describe, it, expect } from 'vitest'
import type { BookElement, BookSpread } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { exportSpread, importSpread, MAX_SPREAD_FILE_BYTES } from '../../../src/components/Studio/spreadFile'
/**
* A spread leaving one book and arriving in another (#1973).
*
* Two things are being tested here, and the second matters more than the first:
* that a design survives the round trip onto a different page size, and that a
* file somebody else wrote cannot put anything into the document that the
* person opening it did not ask for.
*/
const square = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210 })
const a4 = bookPageSetupSchema.parse({ preset: 'a4-landscape', pageWidth: 297, pageHeight: 210 })
const el = (over: Record<string, unknown>): BookElement => ({
id: 'e1', rotation: 0, opacity: 1, locked: false,
frame: { x: 21, y: 42, w: 105, h: 63 },
...over,
} as BookElement)
const spread = (elements: BookElement[], background: string | null = '#faf8f4'): BookSpread => ({
id: 'sp1', role: 'inner', background, elements, parked: [], entryId: null,
})
describe('exportSpread', () => {
it('measures everything as a fraction of the page it was drawn on', () => {
const file = exportSpread(spread([el({ kind: 'shape', shape: 'rect', fill: '#112233' })]), square)
expect(file.format).toBe('trek.studio.spread')
expect(file.elements[0]).toMatchObject({
frame: { x: 0.1, y: 0.2, w: 0.5, h: 0.3 },
})
})
it('leaves the photographs behind', () => {
const file = exportSpread(spread([el({ kind: 'photo', photoId: 41 })]), square)
expect((file.elements[0] as { photoId: number | null }).photoId).toBeNull()
})
it('leaves the map source behind, because it can carry a token', () => {
const file = exportSpread(spread([el({
kind: 'map',
tileUrl: 'https://api.mapbox.com/styles/v1/x/static/0,0,2/300x200?access_token=pk.SECRET',
attribution: 'Mapbox',
})]), square)
expect(JSON.stringify(file)).not.toContain('SECRET')
})
it('does not carry the ids across, since they only mean anything at home', () => {
const file = exportSpread(spread([el({ kind: 'shape', shape: 'rect' })]), square)
expect(file.elements[0]).not.toHaveProperty('id')
})
})
describe('importSpread', () => {
it('lays a square design out on a landscape page', () => {
const file = exportSpread(spread([el({ kind: 'shape', shape: 'rect', fill: '#112233' })]), square)
const back = importSpread(file, a4)!
expect(back).not.toBeNull()
// A tenth of the way in, and half the width, on whatever page it lands on.
expect(back.elements[0].frame.x).toBeCloseTo(29.7, 1)
expect(back.elements[0].frame.w).toBeCloseTo(148.5, 1)
expect(back.elements[0].frame.y).toBeCloseTo(42, 1)
})
it('keeps the design as it was drawn', () => {
const file = exportSpread(spread([el({ kind: 'shape', shape: 'rect', fill: '#112233' })]), square)
const back = importSpread(file, a4)!
expect(back.background).toBe('#faf8f4')
expect((back.elements[0] as { fill: string }).fill).toBe('#112233')
})
it('refuses a file that is not one of ours', () => {
expect(importSpread({ hello: 'world' }, a4)).toBeNull()
expect(importSpread(null, a4)).toBeNull()
expect(importSpread({ format: 'trek.studio.spread' }, a4)).toBeNull()
})
it('drops an element the document contract will not have', () => {
const back = importSpread({
format: 'trek.studio.spread',
version: 1,
background: null,
elements: [
{ kind: 'shape', shape: 'rect', fill: '#112233', frame: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } },
{ kind: 'not-a-thing', frame: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } },
],
}, a4)!
expect(back.elements).toHaveLength(1)
})
it('will not take a background that is not a colour', () => {
const back = importSpread({
format: 'trek.studio.spread',
version: 1,
background: 'url(https://example.invalid/pixel.png)',
elements: [{ kind: 'shape', shape: 'rect', frame: { x: 0.1, y: 0.1, w: 0.2, h: 0.2 } }],
}, a4)!
expect(back.background).toBeNull()
})
it('blanks a map source the file tried to bring with it', () => {
const back = importSpread({
format: 'trek.studio.spread',
version: 1,
background: null,
elements: [{
kind: 'map',
tileUrl: 'https://tracker.invalid/{z}/{x}/{y}.png',
attribution: 'x',
frame: { x: 0.1, y: 0.1, w: 0.4, h: 0.4 },
}],
}, a4)!
expect((back.elements[0] as { tileUrl: string }).tileUrl).toBe('')
})
it('takes no more elements than a spread is allowed to hold', () => {
const one = { kind: 'shape', shape: 'rect', frame: { x: 0.1, y: 0.1, w: 0.1, h: 0.1 } }
const back = importSpread({
format: 'trek.studio.spread',
version: 1,
background: null,
elements: Array.from({ length: 400 }, () => one),
}, a4)!
expect(back.elements).toHaveLength(60)
})
it('gives every element an id of its own', () => {
const one = { kind: 'shape', shape: 'rect', frame: { x: 0.1, y: 0.1, w: 0.1, h: 0.1 } }
const back = importSpread({
format: 'trek.studio.spread', version: 1, background: null, elements: [one, one, one],
}, a4)!
expect(new Set(back.elements.map(e => e.id)).size).toBe(3)
})
it('arrives as an inner spread, never as a cover', () => {
const back = importSpread({
format: 'trek.studio.spread', version: 1, background: null,
elements: [{ kind: 'shape', shape: 'rect', frame: { x: 0.1, y: 0.1, w: 0.1, h: 0.1 }, role: 'cover' }],
}, a4)!
expect(back.role).toBe('inner')
})
})
describe('the size limit', () => {
it('is small enough to be a limit and large enough for any real spread', () => {
// A full spread of 60 elements, as JSON, against the ceiling the file
// picker enforces before it reads anything.
const full = exportSpread(spread(Array.from({ length: 60 }, (_, i) =>
el({ id: `e${i}`, kind: 'text', text: 'x'.repeat(200) }))), square)
expect(JSON.stringify(full).length).toBeLessThan(MAX_SPREAD_FILE_BYTES)
expect(MAX_SPREAD_FILE_BYTES).toBeLessThanOrEqual(2 * 1024 * 1024)
})
})
@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach } from 'vitest'
import type { BookDocument, BookElement, BookSpread } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { render } from '../../helpers/render'
import { useStudioStore } from '../../../src/store/studioStore'
import { SpreadView } from '../../../src/components/Studio/SpreadView'
import { snapTargets } from '../../../src/components/Studio/useSpreadInteraction'
/**
* Three fixes to the editor (#1973).
*
* An empty frame that did not wear the frame it will wear, a page added in
* front of the cover with no way back, and a snap that only caught the fold.
*/
const page = bookPageSetupSchema.parse({ preset: 'square-210', pageWidth: 210, pageHeight: 210, safe: 8 })
const frame = (over: Partial<BookElement> = {}): BookElement => ({
id: 'f1', kind: 'photo', frame: { x: 20, y: 20, w: 120, h: 90 },
rotation: 0, opacity: 1, locked: false,
photoId: null, fit: 'cover', focalX: 0.5, focalY: 0.5,
radius: 0, filter: 'none', mask: null, frameStyle: 'none',
...over,
} as BookElement)
const spread = (elements: BookElement[], role: BookSpread['role'] = 'inner'): BookSpread => ({
id: 's1', role, background: '#ffffff', elements, parked: [], entryId: null,
})
const doc = (spreads: BookSpread[]): BookDocument =>
({ version: 1, title: 'T', page, spreads } as BookDocument)
describe('an empty frame', () => {
/*
* It used to render on its own, ignoring frameStyle: a Polaroid placeholder
* was a plain dashed rectangle until a photograph landed in it and the chin
* appeared from nowhere.
*/
it('wears the frame it will wear once it is filled', () => {
const { container } = render(
<SpreadView spread={spread([frame({ frameStyle: 'polaroid' })])} page={page} spreadIndex={1} />,
)
const outer = container.querySelector<HTMLElement>('[style*="left: 20mm"]')!
// A Polaroid's white card and the shadow of a print lying on a page.
expect(outer.style.background).toContain('rgb(255, 255, 255)')
expect(outer.style.boxShadow).toBeTruthy()
})
it('shows a film frame as film, black card and all', () => {
const { container } = render(
<SpreadView spread={spread([frame({ frameStyle: 'film' })])} page={page} spreadIndex={1} />,
)
const outer = container.querySelector<HTMLElement>('[style*="left: 20mm"]')!
expect(outer.style.background).toContain('rgb(20, 20, 20)')
})
it('leaves a plain frame plain', () => {
const { container } = render(
<SpreadView spread={spread([frame()])} page={page} spreadIndex={1} />,
)
const outer = container.querySelector<HTMLElement>('[style*="left: 20mm"]')!
expect(outer.style.boxShadow).toBe('')
})
/* In the bound book an unfilled frame is nothing at all. */
it('prints as nothing', () => {
const { container } = render(
<SpreadView spread={spread([frame({ frameStyle: 'polaroid' })])} page={page} spreadIndex={1} print />,
)
expect(container.querySelector('[style*="left: 20mm"]')).toBeNull()
})
})
describe('what an edge snaps to', () => {
/*
* The renderer draws the safe area per page, so a spread shows a dashed rule
* inset from each of its four vertical edges — including the two either side
* of the fold. Only the outer pair was a target, so dragging inwards caught
* the gutter and nothing else: the line you could see right before it was not
* something the pointer knew about.
*/
it('includes the safe lines either side of the fold', () => {
const { xs } = snapTargets(spread([]), page, new Set())
expect(xs).toContain(210) // the fold
expect(xs).toContain(202) // 210 - safe, the left page's inner rule
expect(xs).toContain(218) // 210 + safe, the right page's
})
it('keeps the outer ones it always had', () => {
const { xs, ys } = snapTargets(spread([]), page, new Set())
expect(xs).toContain(8) // safe
expect(xs).toContain(412) // 420 - safe
expect(ys).toContain(8)
expect(ys).toContain(202) // 210 - safe
})
/* A cover is one page and has no fold to snap to. */
it('has no fold on a single page', () => {
const { xs } = snapTargets(spread([], 'cover'), page, new Set())
expect(xs).not.toContain(218)
})
/*
* On a spread the halfway mark is the fold, not the middle of anything you
* are composing on: a picture centred on its own page had no line to find.
*/
it('includes the centre of each page, which is not the fold', () => {
const { xs } = snapTargets(spread([]), page, new Set())
expect(xs).toContain(105) // middle of the left page
expect(xs).toContain(315) // middle of the right
})
it('includes the quarters, where a two-column split lands', () => {
const { xs, ys } = snapTargets(spread([]), page, new Set())
expect(xs).toContain(52.5)
expect(xs).toContain(157.5)
expect(ys).toContain(52.5)
expect(ys).toContain(157.5)
})
it('centres a single page on itself', () => {
const { xs } = snapTargets(spread([], 'cover'), page, new Set())
expect(xs).toContain(105)
expect(xs).not.toContain(315)
})
it('snaps to the other elements on the page too', () => {
const { xs } = snapTargets(spread([frame({ id: 'other' })]), page, new Set())
expect(xs).toContain(20) // its left edge
expect(xs).toContain(140) // and its right
})
it('ignores the element being dragged, so it cannot snap to itself', () => {
const { xs } = snapTargets(spread([frame({ id: 'me' })]), page, new Set(['me']))
expect(xs).not.toContain(140)
})
})
describe('adding a page', () => {
beforeEach(() => {
useStudioStore.getState().load(doc([spread([], 'cover'), spread([], 'back')]))
})
/*
* The bug: an empty book reports its last inner spread as -1, which asked for
* a page at index 0 — in front of the cover, where it could not be moved back
* from either, because a move only ever swaps with another inner spread.
*/
it('lands after the cover even when the book has no inner pages yet', () => {
useStudioStore.getState().addSpread(-1)
const roles = useStudioStore.getState().doc!.spreads.map(s => s.role)
expect(roles).toEqual(['cover', 'inner', 'back'])
})
it('still lands after the page it was asked for', () => {
useStudioStore.getState().addSpread(-1)
useStudioStore.getState().addSpread(1)
expect(useStudioStore.getState().doc!.spreads.map(s => s.role))
.toEqual(['cover', 'inner', 'inner', 'back'])
})
it('never lands behind the back cover', () => {
useStudioStore.getState().addSpread(99)
const roles = useStudioStore.getState().doc!.spreads.map(s => s.role)
expect(roles[roles.length - 1]).toBe('back')
})
it('selects the page it just made', () => {
useStudioStore.getState().addSpread(-1)
expect(useStudioStore.getState().activeSpread).toBe(1)
})
})
@@ -0,0 +1,313 @@
import { describe, it, expect, beforeEach } from 'vitest'
import type { BookDocument, BookElement, BookSpread } from '@trek/shared'
import { useStudioStore } from '../../../src/store/studioStore'
/**
* Spread management (#1973).
*
* The rule everything else follows from: the cover and the back cover are fixed
* points. A book has exactly one of each, they are single pages, and no amount
* of dragging in the rail should be able to produce a book with two covers or
* none. Every operation here refuses on anything that is not an inner spread.
*/
const el = (id: string): BookElement => ({
id, kind: 'shape', frame: { x: 0, y: 0, w: 10, h: 10 },
rotation: 0, opacity: 1, locked: false,
shape: 'rect', fill: '#000000', gradient: 'none',
stroke: null, strokeWidth: 0, strokeStyle: 'solid', radius: 0,
} as BookElement)
const spread = (id: string, role: BookSpread['role'], elements: BookElement[] = []): BookSpread => ({
id, role, background: null, elements, parked: [], entryId: null,
})
function book(...spreads: BookSpread[]): BookDocument {
return {
version: 1, title: 'T',
page: { preset: 'square-210', pageWidth: 210, pageHeight: 210, bleed: 3, safe: 5 },
spreads,
} as BookDocument
}
const store = () => useStudioStore.getState()
const roles = () => store().doc!.spreads.map(s => s.role)
const ids = () => store().doc!.spreads.map(s => s.id)
beforeEach(() => {
useStudioStore.getState().load(book(
spread('cover', 'cover'),
spread('a', 'inner'),
spread('b', 'inner'),
spread('c', 'inner'),
spread('back', 'back'),
))
})
describe('canEditSpread', () => {
it('is true for an inner spread and false for the covers', () => {
expect(store().canEditSpread(0)).toBe(false)
expect(store().canEditSpread(1)).toBe(true)
expect(store().canEditSpread(4)).toBe(false)
})
it('is false past either end rather than throwing', () => {
expect(store().canEditSpread(-1)).toBe(false)
expect(store().canEditSpread(99)).toBe(false)
})
})
describe('addSpread', () => {
it('inserts after the given spread and selects it', () => {
store().addSpread(1)
expect(ids()).toEqual(['cover', 'a', expect.any(String), 'b', 'c', 'back'])
expect(store().activeSpread).toBe(2)
})
it('inserts an inner spread, whatever it was asked to follow', () => {
store().addSpread(0)
expect(roles()[1]).toBe('inner')
})
it('starts the new spread empty', () => {
store().addSpread(1)
const made = store().doc!.spreads[2]
expect(made.elements).toEqual([])
expect(made.parked).toEqual([])
expect(made.entryId).toBeNull()
})
/*
* The one that keeps the book a book: a page inserted behind the back cover
* is a page nobody would ever turn to.
*/
it('never lands behind the back cover', () => {
store().addSpread(4)
expect(roles()[roles().length - 1]).toBe('back')
expect(roles()).toEqual(['cover', 'inner', 'inner', 'inner', 'inner', 'back'])
})
it('gives the new spread an id of its own', () => {
store().addSpread(1)
expect(new Set(ids()).size).toBe(ids().length)
})
it('is one undo step', () => {
store().addSpread(1)
expect(store().doc!.spreads).toHaveLength(6)
store().undo()
expect(store().doc!.spreads).toHaveLength(5)
})
})
describe('duplicateSpread', () => {
beforeEach(() => {
useStudioStore.getState().load(book(
spread('cover', 'cover'),
spread('a', 'inner', [el('e1'), el('e2')]),
spread('back', 'back'),
))
})
it('copies the contents directly after the original', () => {
store().duplicateSpread(1)
expect(store().doc!.spreads).toHaveLength(4)
expect(store().doc!.spreads[2].elements).toHaveLength(2)
expect(store().activeSpread).toBe(2)
})
/*
* Two elements sharing an id would make selection ambiguous and every lookup
* that follows it wrong — including delete, which would take both.
*/
it('gives the copied elements fresh ids', () => {
store().duplicateSpread(1)
const [original, copy] = [store().doc!.spreads[1], store().doc!.spreads[2]]
expect(copy.id).not.toBe(original.id)
for (const e of copy.elements) {
expect(original.elements.map(o => o.id)).not.toContain(e.id)
}
})
it('refuses on the cover and on the back cover', () => {
store().duplicateSpread(0)
store().duplicateSpread(2)
expect(store().doc!.spreads).toHaveLength(3)
})
})
describe('removeSpread', () => {
it('takes the spread out', () => {
store().removeSpread(2)
expect(ids()).toEqual(['cover', 'a', 'c', 'back'])
})
it('refuses on the cover and on the back cover', () => {
store().removeSpread(0)
store().removeSpread(4)
expect(store().doc!.spreads).toHaveLength(5)
expect(roles()).toEqual(['cover', 'inner', 'inner', 'inner', 'back'])
})
it('lands on the spread that took its place', () => {
store().setActiveSpread(2)
store().removeSpread(2)
expect(store().activeSpread).toBe(2)
expect(ids()[2]).toBe('c')
})
it('clears the selection, since what was selected is gone', () => {
store().select(['e1'])
store().removeSpread(1)
expect(store().selection).toEqual([])
})
it('is undoable', () => {
store().removeSpread(2)
store().undo()
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
})
})
describe('moveSpread', () => {
it('swaps with the spread before it', () => {
store().moveSpread(2, -1)
expect(ids()).toEqual(['cover', 'b', 'a', 'c', 'back'])
expect(store().activeSpread).toBe(1)
})
it('swaps with the spread after it', () => {
store().moveSpread(1, 1)
expect(ids()).toEqual(['cover', 'b', 'a', 'c', 'back'])
expect(store().activeSpread).toBe(2)
})
/*
* Refusing at the ends is what keeps the covers in place without any of the
* callers having to know about roles.
*/
it('will not move an inner spread past the cover', () => {
store().moveSpread(1, -1)
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
})
it('will not move an inner spread past the back cover', () => {
store().moveSpread(3, 1)
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
})
it('will not move the covers themselves', () => {
store().moveSpread(0, 1)
store().moveSpread(4, -1)
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
})
it('is one undo step', () => {
store().moveSpread(1, 1)
store().undo()
expect(ids()).toEqual(['cover', 'a', 'b', 'c', 'back'])
})
})
describe('a book with no covers at all', () => {
beforeEach(() => {
useStudioStore.getState().load(book(spread('a', 'inner'), spread('b', 'inner')))
})
it('still adds at the end', () => {
store().addSpread(1)
expect(store().doc!.spreads).toHaveLength(3)
expect(store().activeSpread).toBe(2)
})
it('lets the last spread be deleted, leaving an empty book rather than failing', () => {
store().removeSpread(1)
store().removeSpread(0)
expect(store().doc!.spreads).toEqual([])
expect(store().activeSpread).toBe(0)
})
})
describe('inserting a spread that arrived from outside', () => {
beforeEach(() => {
useStudioStore.getState().load(book(
spread('cover', 'cover'),
spread('a', 'inner'),
spread('back', 'back'),
))
})
const imported = () => spread('from-a-file', 'inner', [el('x'), el('y')])
it('lands inside the covers and becomes the spread being edited', () => {
store().insertSpread(1, imported())
expect(roles()).toEqual(['cover', 'inner', 'inner', 'back'])
expect(store().activeSpread).toBe(2)
})
it('never lands in front of the cover, whatever index it is given', () => {
store().insertSpread(-1, imported())
expect(roles()[0]).toBe('cover')
})
it('never lands behind the back cover', () => {
store().insertSpread(99, imported())
expect(roles()[roles().length - 1]).toBe('back')
})
it('mints fresh ids, so nothing collides with what is already there', () => {
store().insertSpread(1, imported())
const all = store().doc!.spreads.flatMap(s => s.elements.map(e => e.id))
expect(new Set(all).size).toBe(all.length)
expect(ids()).not.toContain('from-a-file')
})
it('arrives as an inner spread even if the file claimed otherwise', () => {
store().insertSpread(1, { ...imported(), role: 'cover' as const })
expect(roles()).toEqual(['cover', 'inner', 'inner', 'back'])
})
it('is one undo step', () => {
store().insertSpread(1, imported())
store().undo()
expect(ids()).toEqual(['cover', 'a', 'back'])
})
})
describe('turning an element', () => {
beforeEach(() => {
useStudioStore.getState().load(book(spread('a', 'inner', [el('one'), el('two')])))
})
const rotationOf = (id: string) => store().doc!.spreads[0].elements.find(e => e.id === id)!.rotation
it('turns the one it was given and leaves the rest alone', () => {
store().setRotation(0, 'one', 45)
expect(rotationOf('one')).toBe(45)
expect(rotationOf('two')).toBe(0)
})
/*
* A drag calls this on every pointer move. If each call were its own undo
* step, undoing a turn would take a hundred presses — so the gesture owns the
* step, and this does not touch the stack by itself.
*/
it('is not an undo step on its own', () => {
store().setRotation(0, 'one', 45)
expect(store().canUndo()).toBe(false)
})
it('is one undo step for the whole gesture', () => {
store().beginGesture()
for (const deg of [5, 10, 15, 20]) store().setRotation(0, 'one', deg)
store().endGesture()
expect(rotationOf('one')).toBe(20)
store().undo()
expect(rotationOf('one')).toBe(0)
})
it('ignores an id that is not on the spread', () => {
store().setRotation(0, 'nope', 45)
expect(rotationOf('one')).toBe(0)
})
})
+150
View File
@@ -0,0 +1,150 @@
import { describe, it, expect } from 'vitest'
import type { BookPageSetup } from '@trek/shared'
import { bookPageSetupSchema } from '@trek/shared'
import { COVER_TEMPLATES, TEMPLATES, applyTemplate } from '../../../src/components/Studio/templates'
/**
* Layouts (#1973).
*
* The cover set exists because a cover is a different problem from a spread:
* one page, type as the subject rather than as a caption. A spread layout
* applied to it lands half its frames past the edge of the page, which is why
* the two sets are kept apart rather than filtered out of one.
*/
const page: BookPageSetup = bookPageSetupSchema.parse({
preset: 'square-210', pageWidth: 210, pageHeight: 210,
})
describe('the layout catalogue', () => {
it('gives every layout a unique id across both sets', () => {
const ids = [...TEMPLATES, ...COVER_TEMPLATES].map(t => t.id)
expect(new Set(ids).size).toBe(ids.length)
})
it('gives every layout at least one slot', () => {
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
expect(tpl.build(page).length, tpl.id).toBeGreaterThan(0)
}
})
it('counts its photo slots honestly', () => {
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
const drawn = tpl.build(page).filter(s => s.kind === 'photo').length
expect(drawn, tpl.id).toBe(tpl.photoSlots)
}
})
it('gives every slot a frame with a positive size', () => {
for (const tpl of [...TEMPLATES, ...COVER_TEMPLATES]) {
for (const slot of tpl.build(page)) {
expect(slot.frame.w, tpl.id).toBeGreaterThan(0)
expect(slot.frame.h, tpl.id).toBeGreaterThan(0)
}
}
})
})
describe('the cover set', () => {
it('is marked as single-page throughout', () => {
for (const tpl of COVER_TEMPLATES) {
expect(tpl.role, tpl.id).toBe('single')
}
})
/*
* The rule that makes them a separate set: everything stays on one page.
* A frame reaching past pageWidth would be half in the fold of a book that
* has no fold there.
*/
it('keeps every frame on the one page', () => {
for (const tpl of COVER_TEMPLATES) {
for (const slot of tpl.build(page)) {
expect(slot.frame.x, `${tpl.id} x`).toBeGreaterThanOrEqual(-page.bleed - 0.01)
expect(slot.frame.x + slot.frame.w, `${tpl.id} right`)
.toBeLessThanOrEqual(page.pageWidth + page.bleed + 0.01)
expect(slot.frame.y + slot.frame.h, `${tpl.id} bottom`)
.toBeLessThanOrEqual(page.pageHeight + page.bleed + 0.01)
}
}
})
it('always has somewhere for the title', () => {
for (const tpl of COVER_TEMPLATES) {
expect(tpl.build(page).some(s => s.kind === 'heading'), tpl.id).toBe(true)
}
})
it('includes one that needs no photograph at all', () => {
expect(COVER_TEMPLATES.some(t => t.photoSlots === 0)).toBe(true)
})
})
describe('the spread set', () => {
it('is not marked single, so the panel keeps the two apart', () => {
for (const tpl of TEMPLATES) {
expect(tpl.role ?? 'inner', tpl.id).toBe('inner')
}
})
it('stays within the spread', () => {
for (const tpl of TEMPLATES) {
for (const slot of tpl.build(page)) {
expect(slot.frame.x + slot.frame.w, `${tpl.id} right`)
.toBeLessThanOrEqual(page.pageWidth * 2 + page.bleed + 0.01)
}
}
})
it('offers a range of photo counts rather than five versions of one idea', () => {
const counts = new Set(TEMPLATES.map(t => t.photoSlots))
expect(counts.size).toBeGreaterThanOrEqual(6)
})
})
describe('applying a cover layout', () => {
const spread = {
id: 'c1', role: 'cover' as const, background: null, parked: [], entryId: null,
elements: [
{
id: 'p1', kind: 'photo' as const, frame: { x: 0, y: 0, w: 50, h: 50 },
rotation: 0, opacity: 1, locked: false, photoId: 42, fit: 'cover' as const,
focalX: 0.5, focalY: 0.5, radius: 0, filter: 'none' as const,
mask: null, frameStyle: 'none' as const,
},
{
id: 't1', kind: 'text' as const, frame: { x: 0, y: 60, w: 100, h: 20 },
rotation: 0, opacity: 1, locked: false, text: 'Iceland', font: 'sans' as const,
size: 30, weight: 700 as const, italic: false, align: 'left' as const,
leading: 1.1, tracking: 0, color: '#111111', binding: null, overridden: false,
},
],
}
it('pours the existing picture and title into the new arrangement', () => {
const next = applyTemplate(spread, COVER_TEMPLATES[0], page)
const photo = next.elements.find(e => e.kind === 'photo')
const text = next.elements.find(e => e.kind === 'text')
expect(photo?.kind === 'photo' && photo.photoId).toBe(42)
expect(text?.kind === 'text' && text.text).toBe('Iceland')
})
/*
* Trying the type-only cover must not cost the photograph — it comes back
* the moment a layout with a frame is applied.
*/
it('parks what a type-only cover has no room for', () => {
const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)!
const next = applyTemplate(spread, quiet, page)
expect(next.elements.some(e => e.kind === 'photo')).toBe(false)
expect(next.parked.some(e => e.kind === 'photo' && e.photoId === 42)).toBe(true)
})
it('brings the parked picture back on a layout that has room', () => {
const quiet = COVER_TEMPLATES.find(t => t.photoSlots === 0)!
const parked = applyTemplate(spread, quiet, page)
const back = applyTemplate(parked, COVER_TEMPLATES[0], page)
const photo = back.elements.find(e => e.kind === 'photo')
expect(photo?.kind === 'photo' && photo.photoId).toBe(42)
})
})
@@ -0,0 +1,208 @@
import { describe, it, expect } from 'vitest'
import type { BookElement, JourneyStats } from '@trek/shared'
import { isStale, liveMetrics, refreshPatch } from '../../../src/components/Studio/travelRefresh'
/**
* Keeping a travel element in step with its journey (#1973).
*
* The behaviour being pinned is restraint: an element holds its own values so
* the print renderer never needs a network, and the editor offers to update
* them only when they have genuinely stopped matching. Marking a page stale
* because *something* changed would make the prompt meaningless.
*/
const stats: JourneyStats = {
journeyId: 6,
distance: 1_189_000,
days: 14,
steps: 14,
photos: 57,
places: 21,
furthest: 408_000,
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
],
points: [
{ lat: 64.14, lng: -21.94, label: 'Reykjavík', date: '2026-06-02', country: 'IS' },
{ lat: 65.68, lng: -18.12, label: 'Akureyri', date: '2026-06-06', country: 'IS' },
],
start: '2026-06-02',
end: '2026-06-15',
}
const frame = { x: 0, y: 0, w: 100, h: 60 }
const common = { frame, rotation: 0, opacity: 1, locked: false }
const typeset = { font: 'sans' as const, color: '#1a1a1a', accent: '#111111', textScale: 1, weight: 700 as const, stale: false }
function statsEl(over: Partial<Record<string, unknown>> = {}): BookElement {
return {
...common, ...typeset, id: 'st-1', kind: 'stats',
metrics: ['distance', 'days'],
layout: 'grid', showIcons: true, units: 'metric',
values: { distance: 1_189_000, days: 14 },
...over,
} as BookElement
}
function countriesEl(over: Partial<Record<string, unknown>> = {}): BookElement {
return {
...common, ...typeset, id: 'co-1', kind: 'countries',
codes: ['IS'], names: ['Island'],
layout: 'list', showOutline: true, showFlag: false, showName: true, align: 'center',
...over,
} as BookElement
}
function mapEl(over: Partial<Record<string, unknown>> = {}): BookElement {
return {
...common, ...typeset, id: 'mp-1', kind: 'map',
style: 'minimal', showLand: true, showRoute: true, showPins: true, showLabels: false,
countries: ['IS'],
points: stats.points.map(p => ({ lat: p.lat, lng: p.lng, label: p.label })),
...over,
} as BookElement
}
describe('liveMetrics', () => {
it('maps every metric name onto a figure', () => {
expect(liveMetrics(stats)).toEqual({
distance: 1_189_000, days: 14, steps: 14, photos: 57, countries: 1, places: 21, furthest: 408_000,
})
})
it('reports the count of countries, not the list', () => {
expect(liveMetrics(stats).countries).toBe(1)
})
})
describe('isStale', () => {
it('is false with no figures to compare against', () => {
expect(isStale(statsEl(), null)).toBe(false)
})
it('is false for an element that carries no journey figures at all', () => {
const text = { ...common, id: 't-1', kind: 'text', text: 'hi' } as unknown as BookElement
expect(isStale(text, stats)).toBe(false)
})
it('is false while the stored figures still match', () => {
expect(isStale(statsEl(), stats)).toBe(false)
})
it('is true once a shown figure has moved', () => {
expect(isStale(statsEl({ values: { distance: 900_000, days: 14 } }), stats)).toBe(true)
})
/*
* The restraint that makes the prompt worth showing: a summary of distance
* and days does not go stale because somebody uploaded a photograph.
*/
it('ignores a figure the element does not show', () => {
expect(isStale(statsEl({ values: { distance: 1_189_000, days: 14, photos: 3 } }), stats)).toBe(false)
})
it('treats a missing stored value as zero and therefore stale', () => {
expect(isStale(statsEl({ values: { distance: 1_189_000 } }), stats)).toBe(true)
})
it('spots a country added to the journey', () => {
expect(isStale(countriesEl({ codes: [] }), stats)).toBe(true)
expect(isStale(countriesEl(), stats)).toBe(false)
})
it('spots countries reordered, since the list retells the route', () => {
const twoCountries: JourneyStats = {
...stats,
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
{ code: 'FO', name: 'Faroe Islands', places: 2, firstVisit: '2026-06-16' },
],
}
expect(isStale(countriesEl({ codes: ['FO', 'IS'], names: ['a', 'b'] }), twoCountries)).toBe(true)
})
it('spots a route that gained or lost a stop', () => {
expect(isStale(mapEl({ points: [stats.points[0]] }), stats)).toBe(true)
expect(isStale(mapEl(), stats)).toBe(false)
})
it('spots a route of the same length whose ends have moved', () => {
const moved = mapEl({
points: [{ lat: 0, lng: 0, label: '' }, { lat: 65.68, lng: -18.12, label: '' }],
})
expect(isStale(moved, stats)).toBe(true)
})
it('is false for an empty route against an empty journey', () => {
expect(isStale(mapEl({ points: [] }), { ...stats, points: [] })).toBe(false)
})
})
describe('refreshPatch', () => {
it('is null for an element with nothing to refresh', () => {
const text = { ...common, id: 't-1', kind: 'text', text: 'hi' } as unknown as BookElement
expect(refreshPatch(text, stats)).toBeNull()
})
it('takes the current figures and clears the stale flag', () => {
const patch = refreshPatch(statsEl({ values: { distance: 1, days: 1 } }), stats)!
expect(patch).toMatchObject({ stale: false })
expect((patch as { values: Record<string, number> }).values.distance).toBe(1_189_000)
})
it('brings a country list up to date', () => {
const patch = refreshPatch(countriesEl({ codes: [], names: [] }), stats)!
expect((patch as { codes: string[] }).codes).toEqual(['IS'])
})
/*
* Names are the book's language, resolved on the client when the element was
* placed. A refresh must not quietly replace "Island" with the API's English.
*/
it('keeps the name a country already had, in whatever language it was set', () => {
const twoCountries: JourneyStats = {
...stats,
countries: [
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
{ code: 'FO', name: 'Faroe Islands', places: 2, firstVisit: '2026-06-16' },
],
}
const patch = refreshPatch(countriesEl(), twoCountries)!
expect((patch as { names: string[] }).names[0]).toBe('Island')
expect((patch as { names: string[] }).names[1]).toBe('Faroe Islands')
})
it('keeps a name matched to its country when the order changes', () => {
const reordered: JourneyStats = {
...stats,
countries: [
{ code: 'FO', name: 'Faroe Islands', places: 2, firstVisit: '2026-05-01' },
{ code: 'IS', name: 'Iceland', places: 14, firstVisit: '2026-06-02' },
],
}
const patch = refreshPatch(countriesEl(), reordered)!
const { codes, names } = patch as { codes: string[]; names: string[] }
expect(codes).toEqual(['FO', 'IS'])
// Iceland keeps the German name it was placed with, now in second position.
expect(names[codes.indexOf('IS')]).toBe('Island')
})
it('replaces the route and the countries a map draws', () => {
const patch = refreshPatch(mapEl({ points: [], countries: [] }), stats)!
expect((patch as { points: unknown[] }).points).toHaveLength(2)
expect((patch as { countries: string[] }).countries).toEqual(['IS'])
})
it('carries only lat, lng and label onto the map, not the whole stats point', () => {
const patch = refreshPatch(mapEl({ points: [] }), stats)!
expect((patch as { points: object[] }).points[0]).toEqual({
lat: 64.14, lng: -21.94, label: 'Reykjavík',
})
})
it('leaves the element stale-free after refreshing, so the prompt goes away', () => {
const el = statsEl({ values: { distance: 1, days: 1 } })
const patched = { ...el, ...refreshPatch(el, stats) } as BookElement
expect(isStale(patched, stats)).toBe(false)
})
})
+107
View File
@@ -0,0 +1,107 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { fireEvent, render, screen } from '../../helpers/render'
import { TrimField } from '../../../src/components/Studio/TrimField'
/**
* Typing a trim size (#1973).
*
* The field clamped on every keystroke, so typing 500 meant it saw 5, clamped
* that to the minimum and wrote it back under the cursor: the number could not
* be typed at all, only nudged with the arrows. What follows is the behaviour
* that fixes it, and the two things the fix must not break — the arrows, and
* leaving the field with something unusable in it.
*/
const onCommit = vi.fn()
function open(value = 210) {
render(<TrimField label="Width" value={value} onCommit={onCommit} />)
return screen.getByLabelText('Width') as HTMLInputElement
}
beforeEach(() => { onCommit.mockReset() })
describe('typing', () => {
/* The whole point: a number gets to be typed in full before it is judged. */
it('does not clamp a half-typed number back under the cursor', () => {
const input = open()
fireEvent.change(input, { target: { value: '5' } })
expect(input.value).toBe('5')
expect(onCommit).not.toHaveBeenCalled()
})
it('keeps taking keystrokes while the number is still too small', () => {
const input = open()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.change(input, { target: { value: '50' } })
expect(input.value).toBe('50')
expect(onCommit).not.toHaveBeenCalled()
})
it('commits as soon as what was typed can be used', () => {
const input = open()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.change(input, { target: { value: '50' } })
fireEvent.change(input, { target: { value: '500' } })
expect(onCommit).toHaveBeenCalledWith(500)
})
/* The arrows produce values already in range, and those go straight through. */
it('lets the arrows work exactly as they did', () => {
const input = open()
fireEvent.change(input, { target: { value: '211' } })
expect(onCommit).toHaveBeenCalledWith(211)
})
it('shows the document size again once a committed value comes back', () => {
const input = open()
fireEvent.change(input, { target: { value: '240' } })
expect(onCommit).toHaveBeenCalledWith(240)
// The draft is dropped on commit, so the field follows the document rather
// than holding a stale copy of what was typed.
expect(input.value).toBe('210')
})
})
describe('leaving the field', () => {
/*
* Someone who typed 5 and clicked away meant the smallest book, not the one
* they started with — so the clamp still happens, just at the end.
*/
it('commits what is there, out of range and all', () => {
const input = open()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith(5)
})
it('leaves the size alone when the field was emptied', () => {
const input = open()
fireEvent.change(input, { target: { value: '' } })
fireEvent.blur(input)
expect(onCommit).not.toHaveBeenCalled()
expect(input.value).toBe('210')
})
it('commits on enter without waiting for a click elsewhere', () => {
const input = open()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.keyDown(input, { key: 'Enter' })
fireEvent.blur(input)
expect(onCommit).toHaveBeenCalledWith(5)
})
it('drops the draft on escape and shows the real size again', () => {
const input = open()
fireEvent.change(input, { target: { value: '5' } })
fireEvent.keyDown(input, { key: 'Escape' })
expect(input.value).toBe('210')
expect(onCommit).not.toHaveBeenCalled()
})
})
describe('what it shows', () => {
it('rounds to a tenth, the way the document stores it', () => {
expect(open(210.06).value).toBe('210.1')
})
})
@@ -0,0 +1,191 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook } from '@testing-library/react'
/**
* Presence and pointers (#1973).
*
* The parts worth pinning are the ones that leave something behind when they
* are wrong: an arrow belonging to somebody who has closed the tab, a pointer
* drawn on the wrong spread, and a mousemove handler that puts sixty frames a
* second onto the socket.
*/
const ws = vi.hoisted(() => ({
listeners: new Set<(e: Record<string, unknown>) => void>(),
joinBook: vi.fn(),
leaveBook: vi.fn(),
sendBookCursor: vi.fn(),
}))
vi.mock('../../../src/api/websocket', () => ({
addListener: (fn: (e: Record<string, unknown>) => void) => { ws.listeners.add(fn) },
removeListener: (fn: (e: Record<string, unknown>) => void) => { ws.listeners.delete(fn) },
joinBook: ws.joinBook,
leaveBook: ws.leaveBook,
sendBookCursor: ws.sendBookCursor,
}))
import { useBookPresence } from '../../../src/components/Studio/useBookPresence'
/** Deliver a frame the way the gateway broadcasts it. */
function deliver(event: Record<string, unknown>) {
act(() => { for (const fn of ws.listeners) fn(event) })
}
const peersFrame = (journeyId: number, peers: unknown[]) =>
({ type: 'journey:book:peers', journeyId, peers })
const cursorFrame = (over: Record<string, unknown> = {}) => ({
type: 'journey:book:cursor',
journeyId: 9,
socketId: 3,
userId: 2,
spreadIndex: 0,
x: 100,
y: 50,
...over,
})
const ada = { socketId: 3, userId: 2, username: 'ada', avatar: null }
beforeEach(() => {
vi.useFakeTimers()
ws.listeners.clear()
ws.joinBook.mockReset()
ws.leaveBook.mockReset()
ws.sendBookCursor.mockReset()
})
afterEach(() => { vi.useRealTimers() })
describe('joining', () => {
it('joins the book and leaves it again when the editor closes', () => {
const { unmount } = renderHook(() => useBookPresence(9))
expect(ws.joinBook).toHaveBeenCalledWith(9)
unmount()
expect(ws.leaveBook).toHaveBeenCalledWith(9)
expect(ws.listeners.size).toBe(0)
})
it('takes the list of who is here', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(9, [ada]))
expect(result.current.peers).toEqual([ada])
})
it('ignores another journey entirely', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(11, [ada]))
expect(result.current.peers).toEqual([])
})
})
describe('the pointers', () => {
it('keeps one per socket, at the latest position', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(9, [ada]))
deliver(cursorFrame({ x: 100 }))
deliver(cursorFrame({ x: 140 }))
expect(result.current.cursors).toHaveLength(1)
expect(result.current.cursors[0].x).toBe(140)
})
it('names the pointer from the presence list, so the frame need not carry it', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(9, [ada]))
deliver(cursorFrame())
expect(result.current.cursors[0].username).toBe('ada')
})
it('keeps two people apart', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(9, [ada, { socketId: 4, userId: 5, username: 'bo', avatar: null }]))
deliver(cursorFrame({ socketId: 3 }))
deliver(cursorFrame({ socketId: 4, userId: 5, x: 10 }))
expect(result.current.cursors.map(c => c.socketId).sort()).toEqual([3, 4])
})
/* Null coordinates mean the pointer left the page. */
it('drops a pointer that left the page', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(9, [ada]))
deliver(cursorFrame())
expect(result.current.cursors).toHaveLength(1)
deliver(cursorFrame({ x: null, y: null }))
expect(result.current.cursors).toEqual([])
})
/*
* The one that leaves a ghost: an arrow still on the page after its owner has
* gone, belonging to nobody.
*/
it('drops the pointer of somebody who left the book', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(9, [ada]))
deliver(cursorFrame())
expect(result.current.cursors).toHaveLength(1)
deliver(peersFrame(9, []))
expect(result.current.cursors).toEqual([])
})
it('carries the spread, so a pointer on page 40 is not drawn on page 2', () => {
const { result } = renderHook(() => useBookPresence(9))
deliver(peersFrame(9, [ada]))
deliver(cursorFrame({ spreadIndex: 20 }))
expect(result.current.cursors[0].spreadIndex).toBe(20)
})
})
describe('sending this pointer', () => {
/*
* Called from a mousemove, so it is called constantly. Sixty frames a second
* of something the eye cannot use is the whole reason this is throttled.
*/
it('sends one frame for a burst of movement', () => {
const { result } = renderHook(() => useBookPresence(9))
act(() => {
for (let i = 0; i < 40; i++) result.current.moveCursor(0, i, i)
})
act(() => { vi.advanceTimersByTime(120) })
expect(ws.sendBookCursor).toHaveBeenCalledTimes(1)
// The last position, not the first: where the pointer ended up is the only
// part of its history anybody wants.
expect(ws.sendBookCursor).toHaveBeenCalledWith(9, 0, 39, 39)
})
it('keeps sending while the pointer keeps moving', () => {
const { result } = renderHook(() => useBookPresence(9))
act(() => { result.current.moveCursor(0, 1, 1) })
act(() => { vi.advanceTimersByTime(120) })
act(() => { result.current.moveCursor(0, 2, 2) })
act(() => { vi.advanceTimersByTime(120) })
expect(ws.sendBookCursor).toHaveBeenCalledTimes(2)
})
/* Leaving goes at once — an arrow lingering where somebody is not is worse. */
it('sends the pointer leaving immediately', () => {
const { result } = renderHook(() => useBookPresence(9))
act(() => { result.current.moveCursor(0, 5, 5) })
act(() => { result.current.moveCursor(0, null, null) })
expect(ws.sendBookCursor).toHaveBeenCalledWith(9, 0, null, null)
})
it('sends nothing more once the editor is gone', () => {
const { result, unmount } = renderHook(() => useBookPresence(9))
act(() => { result.current.moveCursor(0, 1, 1) })
unmount()
act(() => { vi.advanceTimersByTime(500) })
expect(ws.sendBookCursor).not.toHaveBeenCalled()
})
})
@@ -0,0 +1,517 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
import { act, renderHook } from '@testing-library/react'
import type { BookDocument, BookRecord } from '@trek/shared'
/**
* Autosaving a Studio book (#1973).
*
* The document used to live as long as the tab did. What replaced that is a
* debounce, a queue and a conflict state, and each of those is a place where
* someone's work can quietly go missing — a save dropped because another was in
* flight, a 409 swallowed as a generic error, a pending edit abandoned on close.
* These tests are aimed at exactly those three.
*/
const api = vi.hoisted(() => ({
getBook: vi.fn(),
saveBook: vi.fn(),
deleteBook: vi.fn(),
}))
vi.mock('../../../src/api/client', () => ({ journeyApi: api }))
/** The websocket seam, so a save from another editor can be delivered by hand. */
const listeners = vi.hoisted(() => new Set<(e: Record<string, unknown>) => void>())
vi.mock('../../../src/api/websocket', () => ({
addListener: (fn: (e: Record<string, unknown>) => void) => { listeners.add(fn) },
removeListener: (fn: (e: Record<string, unknown>) => void) => { listeners.delete(fn) },
}))
/** Deliver a `journey:book:saved` the way the server broadcasts it. */
async function remoteSave(version = 6, journeyId = 9) {
await act(async () => {
for (const fn of listeners) fn({ type: 'journey:book:saved', journeyId, version, savedBy: 2 })
for (let i = 0; i < 10; i++) await Promise.resolve()
})
}
import { useBookStore } from '../../../src/components/Studio/useBookStore'
const doc = (title = 'one') => ({ version: 1, title, page: {}, spreads: [] } as unknown as BookDocument)
const record = (over: Partial<BookRecord> = {}): BookRecord => ({
id: 3, journeyId: 9, title: 'T', version: 1,
updatedAt: '2026-08-19 10:00:00', updatedBy: 1,
document: doc(),
...over,
} as BookRecord)
/** A 409 as axios reports it — the shape useBookStore has to recognise. */
function conflictError(current: BookRecord) {
return Object.assign(new Error('Request failed'), {
response: { status: 409, data: { error: 'Book was changed by someone else', current } },
})
}
/**
* Advance past the debounce and let the request settle.
*
* Written out rather than reached for via waitFor: waitFor polls on a timer,
* and the timers here are fake, so it would sit spinning until the test timed
* out. Draining the microtask queue by hand is what actually works.
*/
async function tick(ms = 1300) {
await act(async () => {
vi.advanceTimersByTime(ms)
for (let i = 0; i < 10; i++) await Promise.resolve()
})
}
beforeEach(() => {
vi.useFakeTimers()
listeners.clear()
api.getBook.mockReset().mockResolvedValue({ book: null })
api.saveBook.mockReset().mockResolvedValue(record({ version: 2 }))
})
afterEach(() => {
vi.useRealTimers()
})
/** Render the hook and let the initial load settle. */
async function mount(journeyId = 9) {
const view = renderHook(() => useBookStore(journeyId))
await tick(0)
return view
}
// -- Loading ------------------------------------------------------------------
describe('loading', () => {
it('reports loaded even when the journey has no book yet', async () => {
const { result } = await mount()
expect(result.current.loaded).toBe(true)
expect(result.current.record).toBeNull()
})
it('keeps the version it loaded, so the first save is not a blind write', async () => {
api.getBook.mockResolvedValue({ book: record({ version: 7 }) })
const { result } = await mount()
act(() => { result.current.queueSave(doc('two'), 'T') })
await tick()
expect(api.saveBook).toHaveBeenCalledWith(9, expect.objectContaining({ baseVersion: 7 }))
})
/*
* Opening is not editing.
*
* The editor hands the loaded document straight back on its next render, and
* for a while that counted as a change: every open bumped the version and
* showed up in the history as an edit nobody made. The store recognises the
* document it just handed out by identity, which is also why it — and not the
* editor — is where normalisation happens.
*/
it('does not save the book it just loaded', async () => {
api.getBook.mockResolvedValue({ book: record({ version: 4 }) })
const { result } = await mount()
act(() => { result.current.queueSave(result.current.record!.document, 'T') })
await tick()
expect(api.saveBook).not.toHaveBeenCalled()
})
/* A document missing a field the contract has since gained still has to open. */
it('fills in what an older build left out', async () => {
api.getBook.mockResolvedValue({
book: record({ document: { version: 1, title: 'old', spreads: [] } as never }),
})
const { result } = await mount()
expect(result.current.record!.document.page.pageNumbers.show).toBe(false)
})
/*
* A book that will not load is a book that gets created. Studio has to open
* either way — refusing to start because the GET failed would turn a blip
* into "I cannot get at my book".
*/
it('still opens when the load fails', async () => {
api.getBook.mockRejectedValue(new Error('offline'))
const { result } = await mount()
expect(result.current.loaded).toBe(true)
expect(result.current.record).toBeNull()
})
})
// -- Debounce -----------------------------------------------------------------
describe('debounce', () => {
it('does not write while the document is still moving', async () => {
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
act(() => { vi.advanceTimersByTime(800) })
act(() => { result.current.queueSave(doc('b'), 'T') })
act(() => { vi.advanceTimersByTime(800) })
expect(api.saveBook).not.toHaveBeenCalled()
})
/* A drag is hundreds of commits and one edit. */
it('collapses a burst into a single write, carrying the last document', async () => {
const { result } = await mount()
act(() => {
for (let i = 0; i < 40; i++) result.current.queueSave(doc(`step-${i}`), 'T')
})
await tick()
expect(api.saveBook).toHaveBeenCalledTimes(1)
expect(api.saveBook.mock.calls[0][1].document.title).toBe('step-39')
})
/*
* Someone typing a caption never pauses long enough to trigger the quiet
* timer. Without the ceiling their work would sit unwritten for as long as
* they kept going.
*/
it('writes anyway once it has been dirty too long', async () => {
const { result } = await mount()
for (let i = 0; i < 20; i++) {
act(() => { result.current.queueSave(doc(`t${i}`), 'T') })
act(() => { vi.advanceTimersByTime(1000) })
}
await tick(0)
expect(api.saveBook).toHaveBeenCalled()
})
it('goes through saving to saved', async () => {
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
act(() => { vi.advanceTimersByTime(1300) })
expect(result.current.state.status).toBe('saving')
await tick(0)
expect(result.current.state.status).toBe('saved')
})
it('takes the version back from the server, so the next save is not stale', async () => {
api.saveBook.mockResolvedValue(record({ version: 5 }))
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
await tick()
act(() => { result.current.queueSave(doc('b'), 'T') })
await tick()
expect(api.saveBook.mock.calls[1][1].baseVersion).toBe(5)
})
})
// -- Queueing -----------------------------------------------------------------
describe('a save during a save', () => {
/*
* The one that loses work if it is wrong: an edit made while a request is
* open must be written after it, not dropped because the store was busy.
*/
it('is queued rather than dropped', async () => {
let release: (r: BookRecord) => void = () => {}
api.saveBook.mockImplementationOnce(
() => new Promise<BookRecord>(resolve => { release = resolve }),
)
const { result } = await mount()
act(() => { result.current.queueSave(doc('first'), 'T') })
await tick()
expect(api.saveBook).toHaveBeenCalledTimes(1)
// Arrives while the first request is still open.
act(() => { result.current.queueSave(doc('second'), 'T') })
await act(async () => {
release(record({ version: 2 }))
for (let i = 0; i < 10; i++) await Promise.resolve()
})
expect(api.saveBook).toHaveBeenCalledTimes(2)
expect(api.saveBook.mock.calls[1][1].document.title).toBe('second')
})
it('does not fire two requests at once', async () => {
let open = 0
let peak = 0
api.saveBook.mockImplementation(async () => {
open++
peak = Math.max(peak, open)
await Promise.resolve()
open--
return record({ version: 2 })
})
const { result } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
await tick()
act(() => { result.current.queueSave(doc('b'), 'T') })
await tick()
expect(peak).toBe(1)
})
})
// -- saveNow ------------------------------------------------------------------
describe('saveNow', () => {
/*
* Closing the editor writes what is pending. The debounce exists so a drag is
* one request, not so the last edit before leaving is lost.
*/
it('writes a pending edit without waiting out the debounce', async () => {
const { result } = await mount()
act(() => { result.current.queueSave(doc('unsaved'), 'T') })
await act(async () => { await result.current.saveNow() })
expect(api.saveBook).toHaveBeenCalledTimes(1)
expect(api.saveBook.mock.calls[0][1].document.title).toBe('unsaved')
})
it('is a no-op when there is nothing pending', async () => {
const { result } = await mount()
await act(async () => { await result.current.saveNow() })
expect(api.saveBook).not.toHaveBeenCalled()
})
it('takes a document handed to it directly', async () => {
const { result } = await mount()
await act(async () => { await result.current.saveNow(doc('explicit'), 'Title') })
expect(api.saveBook.mock.calls[0][1].document.title).toBe('explicit')
expect(api.saveBook.mock.calls[0][1].title).toBe('Title')
})
})
// -- Conflicts ----------------------------------------------------------------
describe('conflicts', () => {
it('reads a 409 as the other person, not as a failure', async () => {
api.saveBook.mockRejectedValue(conflictError(record({ version: 6, title: 'Theirs' })))
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('conflict')
expect(result.current.state).toMatchObject({ current: { version: 6, title: 'Theirs' } })
})
it('reports anything else as an error', async () => {
api.saveBook.mockRejectedValue(new Error('network'))
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('error')
})
/*
* A 409 with no record in it is still a 409, but there is nothing to offer a
* choice between — so it reports as an error rather than a conflict the
* editor cannot render.
*/
it('falls back to an error when the 409 carries no record', async () => {
api.saveBook.mockRejectedValue(
Object.assign(new Error('conflict'), { response: { status: 409, data: {} } }),
)
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('error')
})
/*
* The one that turned into a loop on screen: every change queued a save,
* every save was refused, and the refusal re-rendered the thing that queued
* it. A conflict is a question only the user can answer, so nothing is
* written until they have.
*/
it('stops writing until the conflict is answered', async () => {
api.saveBook.mockRejectedValue(conflictError(record({ version: 6 })))
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(api.saveBook).toHaveBeenCalledTimes(1)
// More edits arrive; none of them go out.
act(() => { result.current.queueSave(doc('mine again'), 'T') })
await tick()
act(() => { result.current.queueSave(doc('and again'), 'T') })
await tick()
expect(api.saveBook).toHaveBeenCalledTimes(1)
})
it('writes again once the user has chosen', async () => {
const theirs = record({ version: 6 })
api.saveBook.mockRejectedValueOnce(conflictError(theirs))
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('conflict')
api.saveBook.mockResolvedValue(record({ version: 7 }))
act(() => { result.current.keepMine(theirs) })
act(() => { result.current.queueSave(doc('mine again'), 'T') })
await tick()
expect(api.saveBook).toHaveBeenCalledTimes(2)
})
it('returns their document when their version is taken, and drops the pending write', async () => {
const theirs = record({ version: 6, document: doc('theirs') })
api.saveBook.mockRejectedValue(conflictError(theirs))
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('conflict')
let taken: BookDocument | undefined
act(() => { taken = result.current.acceptTheirs(theirs) })
expect(taken?.title).toBe('theirs')
expect(result.current.state.status).toBe('idle')
// Nothing left queued: taking theirs discards the local edit on purpose.
api.saveBook.mockResolvedValue(record({ version: 7 }))
await act(async () => { await result.current.saveNow() })
expect(api.saveBook).toHaveBeenCalledTimes(1)
})
/*
* Keeping yours is a rebase, not a merge — it moves the local document onto
* their version number so the next save lands instead of conflicting forever.
*/
it('rebases onto their version when the local one is kept', async () => {
const theirs = record({ version: 6 })
api.saveBook.mockRejectedValueOnce(conflictError(theirs))
const { result } = await mount()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('conflict')
act(() => { result.current.keepMine(theirs) })
expect(result.current.state.status).toBe('idle')
api.saveBook.mockResolvedValue(record({ version: 7 }))
act(() => { result.current.queueSave(doc('mine again'), 'T') })
await tick()
expect(api.saveBook.mock.calls[1][1].baseVersion).toBe(6)
})
})
// -- Somebody else saved ------------------------------------------------------
describe('another editor saving', () => {
/** Render with a book already stored, and a spy for what gets handed back. */
async function withBook() {
api.getBook.mockResolvedValue({ book: record({ version: 4, document: doc('theirs') }) })
const onRemote = vi.fn()
const view = renderHook(() => useBookStore(9, onRemote))
await tick(0)
return { ...view, onRemote }
}
it('takes their version when nothing local is outstanding', async () => {
const { result, onRemote } = await withBook()
api.getBook.mockResolvedValue({ book: record({ version: 6, document: doc('newer') }) })
await remoteSave(6)
expect(onRemote).toHaveBeenCalledTimes(1)
expect(onRemote.mock.calls[0][0].title).toBe('newer')
expect(result.current.record?.version).toBe(6)
})
/*
* The one that would lose work: replacing an open document that has unsaved
* edits in it. The next save conflicts instead, which is the whole point of
* the version.
*/
it('leaves an edited document alone', async () => {
const { result, onRemote } = await withBook()
act(() => { result.current.queueSave(doc('mine'), 'T') })
await remoteSave(6)
expect(onRemote).not.toHaveBeenCalled()
})
it('leaves it alone after a conflict too, where nothing is queued but the work is still local', async () => {
const { result, onRemote } = await withBook()
api.saveBook.mockRejectedValue(conflictError(record({ version: 6 })))
act(() => { result.current.queueSave(doc('mine'), 'T') })
await tick()
expect(result.current.state.status).toBe('conflict')
await remoteSave(7)
expect(onRemote).not.toHaveBeenCalled()
})
it('ignores a save on another journey', async () => {
const { onRemote } = await withBook()
await remoteSave(6, 11)
expect(onRemote).not.toHaveBeenCalled()
})
/* A second tab on the same account is a different socket and the same person. */
it('ignores the version it already has', async () => {
const { onRemote } = await withBook()
await remoteSave(4)
expect(onRemote).not.toHaveBeenCalled()
})
it('does not write back what it just took', async () => {
const { result, onRemote } = await withBook()
api.getBook.mockResolvedValue({ book: record({ version: 6, document: doc('newer') }) })
await remoteSave(6)
// The editor hands the loaded document straight back on the next render.
const taken = onRemote.mock.calls[0][0]
act(() => { result.current.queueSave(taken, 'T') })
await tick()
expect(api.saveBook).not.toHaveBeenCalled()
})
it('stops listening once the editor is gone', async () => {
const { unmount } = await withBook()
expect(listeners.size).toBe(1)
unmount()
expect(listeners.size).toBe(0)
})
})
// -- Teardown -----------------------------------------------------------------
describe('unmounting', () => {
it('does not fire a debounced save after the editor is gone', async () => {
const { result, unmount } = await mount()
act(() => { result.current.queueSave(doc('a'), 'T') })
unmount()
await tick(5000)
expect(api.saveBook).not.toHaveBeenCalled()
})
})
+31
View File
@@ -22,6 +22,19 @@ export default defineConfig(({ mode }) => ({
enabled: true,
type: 'module',
suppressWarnings: true,
/*
* No navigation fallback from the dev service worker.
*
* `workbox.navigateFallback` below is right for production: offline, a
* deep link has to be answered by the cached shell. In development it
* means the worker answers a reload with the index.html it cached
* earlier, whose <script> tags point at module URLs from before the last
* restart — so the browser faithfully re-runs the previous version of
* the app while the editor shows the new one, and every explanation for
* that is wrong until someone thinks of the service worker. It cost
* several rounds of "I don't see the change" to find.
*/
navigateFallback: undefined,
},
workbox: {
// Anything above this is dropped from the precache manifest. The build does
@@ -225,8 +238,26 @@ export default defineConfig(({ mode }) => ({
},
},
},
/*
* Never pre-bundle the workspace's own contracts.
*
* `@trek/shared` resolves to shared/dist, which is a build output that changes
* whenever a Zod schema does. Pre-bundled, the dev server keeps serving the
* copy it optimised at startup: a field added to the document contract is
* missing in the browser, `normalizeBookDocument` strips it on load, and the
* change you just made vanishes on reload with nothing in the console. It
* cost an afternoon twice before anyone worked out it was not the code.
*/
optimizeDeps: {
exclude: ['@trek/shared'],
},
server: {
port: 5173,
// And watch the build output, so rebuilding shared reloads the page rather
// than leaving a stale module graph behind.
watch: {
ignored: ['!**/shared/dist/**'],
},
proxy: {
'/api': {
target: 'http://localhost:3001',
+96 -23
View File
@@ -28,8 +28,13 @@
"name": "@trek/client",
"version": "3.4.1",
"dependencies": {
"@fontsource/bebas-neue": "^5.3.0",
"@fontsource/eb-garamond": "^5.3.0",
"@fontsource/geist-sans": "^5.2.5",
"@fontsource/inter": "^5.3.0",
"@fontsource/lora": "^5.3.0",
"@fontsource/museomoderno": "^5.3.0",
"@fontsource/playfair-display": "^5.3.0",
"@fontsource/poppins": "^5.2.7",
"@simplewebauthn/browser": "^13.1.2",
"@trek/shared": "*",
@@ -2918,6 +2923,24 @@
}
}
},
"node_modules/@fontsource/bebas-neue": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/bebas-neue/-/bebas-neue-5.3.0.tgz",
"integrity": "sha512-nCxMEb9Bg9sjNWm9TIOsvHKDMLRUfEZPWpLgYu6m9KavZZVSA/WoHzI5wROwqA+eNwEi/PNBAcB5Co7HA3S0Cw==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/eb-garamond": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/eb-garamond/-/eb-garamond-5.3.0.tgz",
"integrity": "sha512-VplwHpB8FLuzl+4G5LDci1b3Z4SghiuXH8XEgaxjnOb5aue5mEt1njmDKeO6iyLeGa73LffU7PC7hzncLRx0ZA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/geist-sans": {
"version": "5.2.5",
"resolved": "https://registry.npmjs.org/@fontsource/geist-sans/-/geist-sans-5.2.5.tgz",
@@ -2927,6 +2950,24 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/inter": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/inter/-/inter-5.3.0.tgz",
"integrity": "sha512-RofMylZmjlJEfELXeNHFWBRcSs75rGU/6bV2S2jfnvv/3rPXPGe0LgUJTklcHZ9lM4OZmAVFhcJPnACfb91A3g==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/lora": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/lora/-/lora-5.3.0.tgz",
"integrity": "sha512-OK1g1z01rHVPqO5aw/FOIoDqc46KKYZdgL3NOLkxLWDk6QhDHhdr/h2BV1cke6gTvR1tzpDfdKyUkGsC+sQLeQ==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/museomoderno": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/museomoderno/-/museomoderno-5.3.0.tgz",
@@ -2936,6 +2977,15 @@
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/playfair-display": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource/playfair-display/-/playfair-display-5.3.0.tgz",
"integrity": "sha512-uJSABalvdWc10fEPppQjQocamT21LEyRfzzWfRfqbkPz+sRv+kKpYDKyluBUskvJREkJTos9epj1WjQROGu99Q==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@fontsource/poppins": {
"version": "5.2.7",
"resolved": "https://registry.npmjs.org/@fontsource/poppins/-/poppins-5.2.7.tgz",
@@ -6333,7 +6383,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-android-arm64": {
"version": "4.62.0",
@@ -6347,7 +6398,8 @@
"optional": true,
"os": [
"android"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-arm64": {
"version": "4.62.0",
@@ -6361,7 +6413,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-darwin-x64": {
"version": "4.62.0",
@@ -6375,7 +6428,8 @@
"optional": true,
"os": [
"darwin"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-arm64": {
"version": "4.62.0",
@@ -6389,7 +6443,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-freebsd-x64": {
"version": "4.62.0",
@@ -6403,7 +6458,8 @@
"optional": true,
"os": [
"freebsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-gnueabihf": {
"version": "4.62.0",
@@ -6417,7 +6473,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm-musleabihf": {
"version": "4.62.0",
@@ -6431,7 +6488,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-gnu": {
"version": "4.62.0",
@@ -6445,7 +6503,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-arm64-musl": {
"version": "4.62.0",
@@ -6472,7 +6531,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-loong64-musl": {
"version": "4.62.0",
@@ -6486,7 +6546,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-gnu": {
"version": "4.62.0",
@@ -6500,7 +6561,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-ppc64-musl": {
"version": "4.62.0",
@@ -6514,7 +6576,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-gnu": {
"version": "4.62.0",
@@ -6528,7 +6591,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-riscv64-musl": {
"version": "4.62.0",
@@ -6542,7 +6606,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-s390x-gnu": {
"version": "4.62.0",
@@ -6556,7 +6621,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-gnu": {
"version": "4.62.0",
@@ -6570,7 +6636,8 @@
"optional": true,
"os": [
"linux"
]
],
"peer": true
},
"node_modules/@rollup/rollup-linux-x64-musl": {
"version": "4.62.0",
@@ -6597,7 +6664,8 @@
"optional": true,
"os": [
"openbsd"
]
],
"peer": true
},
"node_modules/@rollup/rollup-openharmony-arm64": {
"version": "4.62.0",
@@ -6611,7 +6679,8 @@
"optional": true,
"os": [
"openharmony"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-arm64-msvc": {
"version": "4.62.0",
@@ -6625,7 +6694,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-ia32-msvc": {
"version": "4.62.0",
@@ -6639,7 +6709,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-gnu": {
"version": "4.62.0",
@@ -6653,7 +6724,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@rollup/rollup-win32-x64-msvc": {
"version": "4.62.0",
@@ -6667,7 +6739,8 @@
"optional": true,
"os": [
"win32"
]
],
"peer": true
},
"node_modules/@scarf/scarf": {
"version": "1.4.0",
+109
View File
@@ -0,0 +1,109 @@
/**
* Turn hand-built spreads into auto-layout templates.
*
* Reads the book from a journey and writes the spreads out as data: every
* measurement divided by the page it was drawn on, so the same template lays
* out on an A4 landscape book as it does on the 210mm square it was designed
* for. Run from server/, output goes to the client.
*/
const Database = require('better-sqlite3')
const fs = require('fs')
const JOURNEY = Number(process.argv[2] || 7)
const OUT = process.argv[3] || '../client/src/components/Studio/bookTemplates.data.ts'
const db = new Database('data/travel.db', { readonly: true })
const row = db.prepare('SELECT document FROM journey_books WHERE journey_id = ?').get(JOURNEY)
db.close()
if (!row) throw new Error(`no book on journey ${JOURNEY}`)
/*
* Through the contract on the way in, so a template carries every field the
* document has — including any the schema gained since the spread was drawn.
* Without it a template written today is missing tomorrow's defaults and does
* not typecheck as a BookElement.
*/
const { normalizeBookDocument } = require('@trek/shared')
const doc = normalizeBookDocument(JSON.parse(row.document))
const PW = doc.page.pageWidth
const PH = doc.page.pageHeight
const r4 = n => Math.round(n * 10000) / 10000
/** A frame as fractions of one page's width and of the page height. */
function relFrame(f) {
return { x: r4(f.x / PW), y: r4(f.y / PH), w: r4(f.w / PW), h: r4(f.h / PH) }
}
/** Type size as a fraction of the page height, so it scales with the sheet. */
const relSize = n => r4(n / PH)
function element(e) {
// The id travels with the template and is replaced when it is applied —
// dropping it here would leave the element off the BookElement union.
const out = { ...e, frame: relFrame(e.frame) }
if (typeof out.size === 'number') out.size = relSize(out.size)
if (typeof out.radius === 'number') out.radius = r4(out.radius / PW)
if (typeof out.strokeWidth === 'number') out.strokeWidth = r4(out.strokeWidth / PW)
return out
}
const spreads = doc.spreads
.map((s, i) => ({ index: i, role: s.role, background: s.background, elements: s.elements.map(element) }))
.filter(s => s.role === 'inner')
const body = spreads.map((s, i) => ` {
id: 'ref-${i + 1}',
background: ${JSON.stringify(s.background)},
elements: ${JSON.stringify(s.elements, null, 6).replace(/\n/g, '\n ')},
},`).join('\n')
const file = `import type { BookElement } from '@trek/shared'
/**
* The spread templates, as data.
*
* ── Where these come from ────────────────────────────────────────────────
*
* Not from this file's author. They were built by hand in Studio and read back
* out of the document — which is the point: what a page should look like is a
* matter of taste, and taste is not something a layout function arrives at by
* reasoning. The editor is the design tool, and this is its output.
*
* To add one: build the spread in Studio, then run
*
* node scripts/extract-templates.cjs <journeyId>
*
* from server/ and commit what it writes here.
*
* ── Why the numbers are fractions ────────────────────────────────────────
*
* Every frame is divided by the page it was drawn on — x and w by one page's
* width, y and h by its height — and type sizes by the page height. A template
* built on a 210mm square therefore lays out on an A4 landscape book without
* being redrawn, which it would otherwise have to be for every trim size the
* picker offers.
*
* ── What gets filled in ──────────────────────────────────────────────────
*
* Elements carrying a \`binding\` take their text from the entry; empty photo
* frames take its photographs in order; the day, coordinate and country marks
* take what the entry knows about its stop. Everything else — the panels, the
* rules, the shapes that run off the page — is the design, and is kept as it
* was drawn.
*/
export interface SpreadTemplate {
id: string
background: string | null
/** Frames and sizes are fractions — see the note above. */
elements: BookElement[]
}
export const SPREAD_TEMPLATES: SpreadTemplate[] = [
${body}
]
`
fs.writeFileSync(OUT, file)
console.log(`wrote ${spreads.length} templates to ${OUT}`)
+30
View File
@@ -3981,6 +3981,36 @@ function runMigrations(db: Database.Database): void {
db.exec('ALTER TABLE journey_share_tokens ADD COLUMN newest_first INTEGER NOT NULL DEFAULT 0');
}
},
/*
* TREK Studio books (#1973).
*
* One row per book, the document itself stored as JSON. A book is a
* document rather than a graph of records: the editor loads it whole, the
* renderer prints it whole, and nothing ever queries "which books contain a
* heart-shaped frame". Normalising spreads and elements into tables would
* buy queries nobody makes and cost a join on every open plus a schema
* migration for every new element kind.
*
* `updated_at` and `version` are what make concurrent editing possible:
* the version increments on every write, and a client that saves against a
* version it did not read gets told rather than silently overwriting.
*/
() => {
db.exec(`
CREATE TABLE IF NOT EXISTS journey_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
journey_id INTEGER NOT NULL REFERENCES journeys(id) ON DELETE CASCADE,
title TEXT NOT NULL DEFAULT '',
document TEXT NOT NULL,
version INTEGER NOT NULL DEFAULT 1,
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
updated_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_journey_books_journey ON journey_books(journey_id);
`);
},
];
if (currentVersion < migrations.length) {
@@ -0,0 +1,218 @@
import { Injectable } from '@nestjs/common';
import type { BookRecord, BookSummary } from '@trek/shared';
import { normalizeBookDocument } from '@trek/shared';
import { DatabaseService } from '../database/database.service';
import { JourneyDomainService } from './journey-domain.service';
/**
* Storing TREK Studio books.
*
* ── Access ───────────────────────────────────────────────────────────────
*
* A book belongs to its journey, so it inherits the journey's access exactly —
* every contributor can open and edit it. There is no separate book permission
* and there should not be: a second access model over the same object is how
* two rules end up disagreeing about who may do what.
*
* ── Concurrency ──────────────────────────────────────────────────────────
*
* Optimistic, on a version column. Every save states the version it was made
* against; the update only lands if that is still the current one, and the
* whole thing is one statement so two saves arriving together cannot both
* decide they are first. The loser is told, and told *with* the current record,
* so it can show the other version rather than only announcing one exists.
*
* The alternative — last write wins — is not a simpler version of this. It is
* the same thing with the failure moved somewhere nobody sees it.
*/
@Injectable()
export class JourneyBookService {
constructor(
private readonly db: DatabaseService,
private readonly journey: JourneyDomainService,
) {}
/** Null when the journey does not exist or the user cannot reach it. */
private canAccess(journeyId: number, userId: number): boolean {
return !!this.journey.canAccessJourney(journeyId, userId);
}
/**
* Whether this user may open the journey's book at all.
*
* Public so the controller can tell "no book yet" from "no journey" without
* running a query built for something else.
*/
canOpen(journeyId: number, userId: number): boolean {
return this.canAccess(journeyId, userId);
}
private toRecord(row: BookRow): BookRecord {
return {
id: row.id,
journeyId: row.journey_id,
title: row.title,
version: row.version,
updatedAt: row.updated_at,
updatedBy: row.updated_by,
// Never thrown at the caller: a document that cannot be parsed still has
// to open, or a single drifted field would lock someone out of their own
// book. normalizeBookDocument drops what it cannot read.
document: normalizeBookDocument(safeParse(row.document)),
};
}
listBooks(journeyId: number, userId: number): BookSummary[] | null {
if (!this.canAccess(journeyId, userId)) return null;
const rows = this.db
.prepare(`
SELECT id, journey_id, title, version, updated_at, updated_by
FROM journey_books
WHERE journey_id = ?
ORDER BY updated_at DESC, id DESC
`)
.all(journeyId) as Omit<BookRow, 'document'>[];
return rows.map(r => ({
id: r.id,
journeyId: r.journey_id,
title: r.title,
version: r.version,
updatedAt: r.updated_at,
updatedBy: r.updated_by,
}));
}
/**
* The journey's book.
*
* One per journey for now — the table allows more, because a second book of
* the same trip is an obvious thing to want and adding a column later is
* harder than not needing to.
*/
getBook(journeyId: number, userId: number): BookRecord | null {
if (!this.canAccess(journeyId, userId)) return null;
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
FROM journey_books
WHERE journey_id = ?
ORDER BY id ASC
LIMIT 1
`)
.get(journeyId) as BookRow | undefined;
return row ? this.toRecord(row) : null;
}
/**
* Create or update, against a version.
*
* Returns the saved record, or `{ conflict }` when the base version has
* moved. Throwing would be the obvious shape and the wrong one: a conflict is
* an ordinary outcome of two people working, not an exception.
*/
saveBook(
journeyId: number,
userId: number,
input: { title: string; document: unknown; baseVersion?: number },
): { record: BookRecord } | { conflict: BookRecord } | null {
if (!this.canAccess(journeyId, userId)) return null;
const document = JSON.stringify(normalizeBookDocument(input.document));
const existing = this.db
.prepare('SELECT id, version FROM journey_books WHERE journey_id = ? ORDER BY id ASC LIMIT 1')
.get(journeyId) as { id: number; version: number } | undefined;
if (!existing) {
const result = this.db
.prepare(`
INSERT INTO journey_books (journey_id, title, document, version, created_by, updated_by, updated_at)
VALUES (?, ?, ?, 1, ?, ?, CURRENT_TIMESTAMP)
`)
.run(journeyId, input.title, document, userId, userId);
return { record: this.byId(Number(result.lastInsertRowid))! };
}
/*
* The version goes in the WHERE clause rather than being checked first.
* Read-then-write leaves a window between the two in which another save can
* land, and SQLite gives no guarantee across two statements — one UPDATE
* that matches on the version cannot lose that race with itself.
*
* A save with no base version is a first write from a client that has not
* loaded one; it is allowed to take the current version, since refusing it
* would break "open Studio and start editing" for the second person to
* arrive.
*/
const base = input.baseVersion ?? existing.version;
const result = this.db
.prepare(`
UPDATE journey_books
SET title = ?, document = ?, version = version + 1,
updated_by = ?, updated_at = CURRENT_TIMESTAMP
WHERE id = ? AND version = ?
`)
.run(input.title, document, userId, existing.id, base);
if (result.changes === 0) {
return { conflict: this.byId(existing.id)! };
}
return { record: this.byId(existing.id)! };
}
deleteBook(journeyId: number, userId: number): boolean | null {
if (!this.canAccess(journeyId, userId)) return null;
const result = this.db
.prepare('DELETE FROM journey_books WHERE journey_id = ?')
.run(journeyId);
return result.changes > 0;
}
/**
* Tell the other editors the book moved on.
*
* The version travels, not the document. Everyone with it open needs to know
* theirs is behind — one integer — and can ask for the rest if they want it.
* Broadcasting a few hundred kilobytes of JSON on every autosave would make
* the notification the size of the thing being edited.
*
* The saver is excluded by socket id, the same way every other TREK mutation
* does it, so the client that just saved does not process its own change.
*/
broadcastSaved(journeyId: number, userId: number, record: BookRecord, socketId?: string) {
this.journey.broadcastJourneyEvent(
journeyId,
'journey:book:saved',
{ version: record.version, savedBy: userId },
socketId,
);
}
private byId(id: number): BookRecord | null {
const row = this.db
.prepare(`
SELECT id, journey_id, title, document, version, updated_at, updated_by
FROM journey_books WHERE id = ?
`)
.get(id) as BookRow | undefined;
return row ? this.toRecord(row) : null;
}
}
interface BookRow {
id: number;
journey_id: number;
title: string;
document: string;
version: number;
updated_at: string | null;
updated_by: number | null;
}
/** JSON that will not parse is an empty document, never an exception. */
function safeParse(raw: string): unknown {
try {
return JSON.parse(raw);
} catch {
return {};
}
}
@@ -3,8 +3,44 @@ import { avatarUrl } from '../common/avatarUrl';
import type { Journey, JourneyEntry, JourneyPhoto, JourneyContributor } from '../../types';
import { DatabaseService } from '../database/database.service';
import { RealtimeService } from '../realtime/realtime.service';
import type { JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import type { JourneyStats, JourneyTrack, TrekWsUserEventName } from '@trek/shared';
import { TrekPhotosRepository } from '../photos/trek-photos.repository';
import { getCountryFromCoords } from '../atlas/atlas-geo';
import { computeJourneyStats, type StatsInputPoint } from './journey-stats';
/**
* English country names for whatever codes a journey turned up.
*
* `Intl.DisplayNames` rather than a bundled table or the admin-0 properties:
* Node has the CLDR data already, the names it gives are the ones every other
* piece of software shows, and parsing 30MB of boundary GeoJSON to read a
* `NAME` field would be an absurd way to learn that IS is Iceland.
*
* English on purpose — the client re-resolves these into the reader's language
* when it places the element, and a book needs the country's name in the
* language the book is written in, not the language of whoever's server it is.
*/
function regionNames(): Intl.DisplayNames | null {
try {
return new Intl.DisplayNames(['en'], { type: 'region' });
} catch {
// A Node built without full ICU has no region names. Codes are a poor
// label but a working one, and a book page is not worth a 500 over.
return null;
}
}
function countryNamesFor(points: { country: string | null }[]): Record<string, string> {
const out: Record<string, string> = {};
const display = regionNames();
for (const p of points) {
if (!p.country) continue;
const code = p.country.toUpperCase();
if (out[code]) continue;
out[code] = (display?.of(code) ?? code) || code;
}
return out;
}
@@ -50,7 +86,14 @@ export class JourneyDomainService {
private broadcastJourneyEvent(
/**
* Tell everyone on a journey that something changed.
*
* Public because the book service needs the same audience — who can see a
* journey is one question with one answer, and a second copy of this walk
* over contributors and owner would be a second answer waiting to disagree.
*/
broadcastJourneyEvent(
journeyId: number,
// Typed against the shared WS registry rather than left as `string`: the
// legacy broadcastToUser took anything, so a typo'd event name shipped as a
@@ -813,6 +856,138 @@ export class JourneyDomainService {
return tracks;
}
/**
* What this journey adds up to — the figures TREK Studio prints on a page.
*
* Derived, never stored: the trips added to the journey carry the places and
* the dates, the journey carries the entries and the photographs, and the
* numbers fall out of those. Nothing here is a column anyone has to keep in
* step.
*
* ── Where the route comes from ─────────────────────────────────────────
*
* The journey's own entries when they carry coordinates, because an entry is
* something that happened somewhere on a day and that is exactly what a route
* is made of. A journey assembled from trips that nobody has written up yet
* has no such entries, and falls back to the places on those trips — which is
* the same route, told by the itinerary instead of by the traveller.
*
* Mixing the two would double back on itself: an entry written about a place
* that is also on the trip would be two stops at one location, and the
* distance would count the leg twice.
*
* ── Where the countries come from ──────────────────────────────────────
*
* `place_regions` first: Atlas already resolves a place to its country and
* caches it there, and reading a cache beats repeating a point-in-polygon
* test against 4MB of boundaries. What is missing falls through to
* `getCountryFromCoords`, which is the same answer computed rather than
* remembered. That import is a plain function from a module built to have no
* DI and no Nest edges, so it costs this domain no coupling to Atlas.
*/
journeyStats(journeyId: number, userId: number): JourneyStats | null {
if (!this.canAccessJourney(journeyId, userId)) return null;
const entryRows = this.db.prepare(`
SELECT id, title, location_name, location_lat, location_lng, entry_date
FROM journey_entries
WHERE journey_id = ?
ORDER BY entry_date ASC, sort_order ASC, id ASC
`).all(journeyId) as {
id: number; title: string | null; location_name: string | null;
location_lat: number | null; location_lng: number | null; entry_date: string | null;
}[];
const tripDates = this.db.prepare(`
SELECT t.start_date AS start, t.end_date AS end
FROM journey_trips jt JOIN trips t ON t.id = jt.trip_id
WHERE jt.journey_id = ?
`).all(journeyId) as { start: string | null; end: string | null }[];
// Ordered the way the trip is walked: by day, then by the order within it.
// A place can be assigned to more than one day (a hotel across three nights
// is one place, three assignments), so the join is aggregated back down to
// one row per place at its earliest day — otherwise the route would visit
// the hotel three times and the distance would count those legs.
const placeRows = this.db.prepare(`
SELECT p.id, p.name, p.lat, p.lng,
MIN(d.date) AS day,
MIN(da.order_index) AS ord
FROM journey_trips jt
JOIN places p ON p.trip_id = jt.trip_id
LEFT JOIN day_assignments da ON da.place_id = p.id
LEFT JOIN days d ON d.id = da.day_id
WHERE jt.journey_id = ?
GROUP BY p.id
ORDER BY day IS NULL, day ASC, ord ASC, p.id ASC
`).all(journeyId) as {
id: number; name: string | null; lat: number | null; lng: number | null;
day: string | null; ord: number | null;
}[];
const placeCount = this.db.prepare(`
SELECT COUNT(*) AS n FROM journey_trips jt
JOIN places p ON p.trip_id = jt.trip_id
WHERE jt.journey_id = ?
`).get(journeyId) as { n: number };
const photoCount = this.db
.prepare('SELECT COUNT(*) AS n FROM journey_photos WHERE journey_id = ?')
.get(journeyId) as { n: number };
// The cached country per place, for whichever of them Atlas has seen.
const cachedCountry = new Map<number, string>();
const placeIds = placeRows.map(p => p.id);
for (let i = 0; i < placeIds.length; i += 400) {
const chunk = placeIds.slice(i, i + 400);
if (!chunk.length) continue;
const rows = this.db
.prepare(`SELECT place_id, country_code FROM place_regions WHERE place_id IN (${chunk.map(() => '?').join(',')})`)
.all(...chunk) as { place_id: number; country_code: string }[];
for (const r of rows) if (r.country_code) cachedCountry.set(r.place_id, r.country_code.toUpperCase());
}
const countryAt = (lat: number, lng: number, placeId?: number): string | null => {
if (placeId != null) {
const cached = cachedCountry.get(placeId);
if (cached) return cached;
}
return getCountryFromCoords(lat, lng);
};
const fromEntries: StatsInputPoint[] = entryRows
.filter(e => Number.isFinite(e.location_lat) && Number.isFinite(e.location_lng))
.map(e => ({
lat: e.location_lat as number,
lng: e.location_lng as number,
label: e.title || e.location_name || '',
date: e.entry_date ?? null,
country: countryAt(e.location_lat as number, e.location_lng as number),
}));
const points: StatsInputPoint[] = fromEntries.length
? fromEntries
: placeRows
.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng))
.map(p => ({
lat: p.lat as number,
lng: p.lng as number,
label: p.name || '',
date: p.day ?? null,
country: countryAt(p.lat as number, p.lng as number, p.id),
}));
return computeJourneyStats({
journeyId,
points,
entries: entryRows.length,
photos: photoCount?.n ?? 0,
places: placeCount?.n ?? 0,
tripDates,
countryNames: countryNamesFor(points),
});
}
listEntries(journeyId: number, userId: number) {
if (!this.canAccessJourney(journeyId, userId)) return null;
@@ -62,7 +62,9 @@ export class JourneyPublicController {
throw new HttpException({ error: 'Not found' }, 404);
}
res.set('Cache-Control', 'public, max-age=86400');
res.sendFile(resolved);
// Root-relative, see photo-resolver.service.ts — an absolute path 404s under
// the Nest ExpressAdapter. The guard above already confined `resolved`.
res.sendFile(path.basename(resolved), { root: path.dirname(resolved) });
return;
}
+205
View File
@@ -0,0 +1,205 @@
import type { JourneyStats, JourneyStatsCountry, JourneyStatsPoint } from '@trek/shared';
/**
* What a journey adds up to — the arithmetic, with no database and no Nest.
*
* Plain module on the same footing as `atlas-geo.ts` and `maps.helpers.ts`: the
* service fetches rows and hands them here, which keeps every rule in this file
* testable against a literal array instead of a fixture database. It is also
* the only place these definitions live, so "what counts as a day" has one
* answer rather than one per caller.
*/
/** Mean Earth radius in metres (IUGG). */
const EARTH_RADIUS = 6371008.8;
/** How many stops a printed route can usefully resolve. */
export const MAX_ROUTE_POINTS = 400;
export interface StatsInputPoint {
lat: number;
lng: number;
label: string;
/** ISO date, when the stop has one. */
date: string | null;
country: string | null;
}
const rad = (deg: number) => (deg * Math.PI) / 180;
/**
* Great-circle metres between two coordinates.
*
* Not a driving distance: there is no routing engine here, consecutive stops
* are not necessarily joined by a road, and a book that prints a road distance
* it never measured is worse than one that says "as the crow flies".
*/
export function haversine(a: { lat: number; lng: number }, b: { lat: number; lng: number }): number {
const dLat = rad(b.lat - a.lat);
const dLng = rad(b.lng - a.lng);
const s = Math.sin(dLat / 2) ** 2
+ Math.cos(rad(a.lat)) * Math.cos(rad(b.lat)) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS * Math.asin(Math.min(1, Math.sqrt(s)));
}
/** Metres along the route, in the order given. */
export function routeDistance(points: { lat: number; lng: number }[]): number {
let total = 0;
for (let i = 1; i < points.length; i++) total += haversine(points[i - 1], points[i]);
return total;
}
/**
* Metres from the first stop to whichever is furthest from it.
*
* Polarsteps calls this "furthest point from home" and measures from where the
* trip started, which is the only definition that needs no extra information —
* TREK does not know where anyone lives, and asking would be a worse book for
* the one fact it adds.
*/
export function furthestFromStart(points: { lat: number; lng: number }[]): number {
if (points.length < 2) return 0;
const start = points[0];
let max = 0;
for (let i = 1; i < points.length; i++) {
const d = haversine(start, points[i]);
if (d > max) max = d;
}
return max;
}
/** The `YYYY-MM-DD` part of an ISO date or datetime, or null if it is neither. */
export function isoDay(value: string | null | undefined): string | null {
if (!value) return null;
const m = /^(\d{4}-\d{2}-\d{2})/.exec(String(value));
return m ? m[1] : null;
}
/**
* Calendar days spanned, inclusive at both ends.
*
* A trip that starts and ends the same day is one day, not zero — the figure a
* book prints under "DAYS" is how long you were away, and nobody describes a
* day trip as lasting no days.
*/
export function daysBetween(start: string | null, end: string | null): number {
const a = isoDay(start);
const b = isoDay(end);
if (!a || !b) return 0;
const from = Date.parse(`${a}T00:00:00Z`);
const to = Date.parse(`${b}T00:00:00Z`);
if (!Number.isFinite(from) || !Number.isFinite(to)) return 0;
return Math.max(1, Math.round((to - from) / 86_400_000) + 1);
}
/**
* Thin a route down to a point budget, keeping the ends.
*
* Evenly spaced rather than clustered: dropping a run from the middle would
* cut the route's shape out, while every n-th stop keeps it and only loses
* detail no printed line could show anyway.
*/
export function thinRoute<T>(points: T[], limit = MAX_ROUTE_POINTS): T[] {
if (points.length <= limit) return points;
const out: T[] = [];
const step = (points.length - 1) / (limit - 1);
for (let i = 0; i < limit; i++) out.push(points[Math.round(i * step)]);
return out;
}
/**
* Countries in the order they were first reached.
*
* Visit order, not alphabetical and not by size: the country page of a travel
* book retells the route, and "Mexico, Belize, Guatemala" is the trip while
* "Belize, Guatemala, Mexico" is a filing cabinet. Countries whose stops carry
* no date fall to the end, in the order they appeared.
*/
export function collectCountries(
points: StatsInputPoint[],
names: Record<string, string>,
): JourneyStatsCountry[] {
const seen = new Map<string, { code: string; places: number; firstVisit: string | null; order: number }>();
points.forEach((p, i) => {
if (!p.country) return;
const code = p.country.toUpperCase();
const day = isoDay(p.date);
const entry = seen.get(code);
if (!entry) {
seen.set(code, { code, places: 1, firstVisit: day, order: i });
return;
}
entry.places += 1;
if (day && (!entry.firstVisit || day < entry.firstVisit)) entry.firstVisit = day;
});
return [...seen.values()]
.sort((a, b) => {
if (a.firstVisit && b.firstVisit && a.firstVisit !== b.firstVisit) {
return a.firstVisit < b.firstVisit ? -1 : 1;
}
if (a.firstVisit && !b.firstVisit) return -1;
if (!a.firstVisit && b.firstVisit) return 1;
return a.order - b.order;
})
.map(c => ({
code: c.code,
name: names[c.code] || c.code,
places: c.places,
firstVisit: c.firstVisit,
}));
}
export interface StatsInput {
journeyId: number;
/** The route, already in the order it should be drawn. */
points: StatsInputPoint[];
entries: number;
photos: number;
places: number;
/** Dates from the trips, used when the entries carry none. */
tripDates: { start: string | null; end: string | null }[];
countryNames: Record<string, string>;
}
/**
* Fold everything into the figures a book puts on a page.
*
* Dates come from the entries when they have them and from the trips otherwise:
* an entry is a thing that happened on a day, so it is the better source, but a
* journey assembled from trips nobody has written entries for still has a
* length, and printing "0 DAYS" over a fortnight in Iceland would be worse than
* using the trip's own dates.
*/
export function computeJourneyStats(input: StatsInput): JourneyStats {
const points = thinRoute(input.points);
const dated = points.map(p => isoDay(p.date)).filter((d): d is string => !!d).sort();
const tripStarts = input.tripDates.map(t => isoDay(t.start)).filter((d): d is string => !!d).sort();
const tripEnds = input.tripDates.map(t => isoDay(t.end)).filter((d): d is string => !!d).sort();
const start = dated[0] ?? tripStarts[0] ?? null;
const end = dated[dated.length - 1] ?? tripEnds[tripEnds.length - 1] ?? null;
const geo = points.filter(p => Number.isFinite(p.lat) && Number.isFinite(p.lng));
return {
journeyId: input.journeyId,
distance: Math.round(routeDistance(geo)),
days: daysBetween(start, end),
steps: input.entries,
photos: input.photos,
places: input.places,
furthest: Math.round(furthestFromStart(geo)),
countries: collectCountries(points, input.countryNames),
points: points.map((p): JourneyStatsPoint => ({
lat: p.lat,
lng: p.lng,
label: p.label,
date: p.date,
country: p.country,
})),
start,
end,
};
}
@@ -22,6 +22,7 @@ import fs from 'node:fs';
import crypto from 'node:crypto';
import type { User } from '../../types';
import { JourneyService } from './journey.service';
import { JourneyBookService } from './journey-book.service';
import { PhotoCaptureBackfillService } from '../memories/photo-capture-backfill.service';
import { AddonGuard } from '../addons/addon.guard';
import { RequireAddon } from '../addons/require-addon.decorator';
@@ -33,6 +34,7 @@ import {
JourneyEntryCreateDto, JourneyEntryPhotoUploadDto, JourneyEntryUpdateDto, JourneyGalleryVideoDto,
JourneyLinkPhotoDto, JourneyPhotoUpdateDto, JourneyPreferencesDto, JourneyProviderPhotosDto,
JourneyReorderEntriesDto, JourneyShareLinkDto, JourneyUpdateDto,
BookSaveDto,
} from './journey.dto';
import { isVideoMime, isVideoExtension, MAX_VIDEO_SIZE } from '../files/files.constants';
import { AllowedFileTypesService } from '../files/allowed-file-types.service';
@@ -123,6 +125,7 @@ const VIDEO_UPLOAD = {
export class JourneyController {
constructor(
private readonly journey: JourneyService,
private readonly books: JourneyBookService,
private readonly captureBackfill: PhotoCaptureBackfillService,
) {}
@@ -457,6 +460,87 @@ export class JourneyController {
return { tracks };
}
/**
* The figures TREK Studio prints on a page — distance, days, steps, photos,
* countries and the route itself (#1973).
*
* Read-only and derived, like /tracks above it: nothing here is stored, it is
* what the trips added to this journey add up to. Studio freezes the answer
* into the book document when an element is placed, so this is called while
* designing and never while printing.
*/
@Get(':id/stats')
stats(@CurrentUser() user: User, @Param('id') id: string) {
const stats = this.journey.journeyStats(Number(id), user.id);
if (!stats) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
return stats;
}
/*
* ── The Studio book (#1973) ──────────────────────────────────────────
*
* A book belongs to its journey and inherits its access exactly: every
* contributor may open and edit it. No second permission model over the same
* object — that is how two rules end up disagreeing about who may do what.
*/
@Get(':id/book')
getBook(@CurrentUser() user: User, @Param('id') id: string) {
const book = this.books.getBook(Number(id), user.id);
if (book === null && !this.books.canOpen(Number(id), user.id)) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
// A journey with no book yet is not an error: Studio opens, lays one out
// and saves it. Answering 404 would make "no book" and "no journey"
// indistinguishable to the client.
return { book };
}
@Put(':id/book')
@HttpCode(200)
saveBook(
@CurrentUser() user: User,
@Param('id') id: string,
@Body() body: BookSaveDto,
@Headers('x-socket-id') socketId?: string,
) {
const result = this.books.saveBook(Number(id), user.id, {
title: body.title ?? '',
document: body.document,
baseVersion: body.baseVersion,
});
if (result === null) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
if ('conflict' in result) {
/*
* 409, with the current record in the body.
*
* Two people editing is the normal case here, not an exception — the
* client needs to be able to show what the other version is, and a bare
* status code would make that a second round trip at exactly the moment
* someone is worried about losing work.
*/
throw new HttpException(
{ error: 'Book was changed by someone else', current: result.conflict },
409,
);
}
this.books.broadcastSaved(Number(id), user.id, result.record, socketId);
return result.record;
}
@Delete(':id/book')
@HttpCode(204)
deleteBook(@CurrentUser() user: User, @Param('id') id: string) {
const removed = this.books.deleteBook(Number(id), user.id);
if (removed === null) {
throw new HttpException({ error: 'Journey not found' }, 404);
}
}
@Post(':id/entries')
createEntry(@CurrentUser() user: User, @Param('id') id: string, @Body() body: JourneyEntryCreateDto, @Headers('x-socket-id') socketId?: string) {
if (!body.entry_date) {
+4
View File
@@ -15,6 +15,7 @@ import {
journeyReorderEntriesRequestSchema,
journeyShareLinkRequestSchema,
journeyUpdateRequestSchema,
bookSaveRequestSchema,
} from '@trek/shared';
/**
@@ -49,3 +50,6 @@ export class JourneyLinkPhotoDto extends createZodDto(journeyLinkPhotoRequestSch
export class JourneyPhotoUpdateDto extends createZodDto(journeyPhotoUpdateRequestSchema) {}
export class JourneyEntryPhotoUploadDto extends createZodDto(journeyEntryPhotoUploadRequestSchema) {}
export class JourneyGalleryVideoDto extends createZodDto(journeyGalleryVideoRequestSchema) {}
/** A Studio book save — the whole document, plus the version it was made against. */
export class BookSaveDto extends createZodDto(bookSaveRequestSchema) {}
+2 -1
View File
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
import { JourneyController } from './journey.controller';
import { JourneyPublicController } from './journey-public.controller';
import { JourneyService } from './journey.service';
import { JourneyBookService } from './journey-book.service';
import { AddonsModule } from '../addons/addons.module';
import { MemoriesModule } from '../memories/memories.module';
import { JourneyDomainModule } from './journey-domain.module';
@@ -22,6 +23,6 @@ import { buildJourneyImageUploadOptions } from './journey.controller';
}),
AuthModule, AddonsModule, MemoriesModule, JourneyDomainModule],
controllers: [JourneyController, JourneyPublicController],
providers: [JourneyService, JourneyMcp],
providers: [JourneyService, JourneyBookService, JourneyMcp],
})
export class JourneyModule {}

Some files were not shown because too many files have changed in this diff Show More