Compare commits

...

11 commits

Author SHA1 Message Date
Doxa Maps
8550142a05 Rebuild poster-design (preview on B1 + B2), and regenerate the tree and SRI 2026-10-01 09:09:57 +00:00
Doxa Maps
eff0945ab9 Merge remote-tracking branch 'origin/country-posters-plugin/india-whole-2' into poster-design-plugin/b3
# Conflicts:
#	package.json
2026-10-01 09:09:10 +00:00
Doxa Maps
dea74fa6ee Merge remote-tracking branch 'origin/development' into poster-design-plugin/b3 2026-10-01 09:08:58 +00:00
Doxa Maps
d1a2dc145b Poster Design: Styles draws the Bless wall panels from the shared panel geometry
wall.bless-panels drew nothing. It now draws the ladder's 5 x 8 ft size
(60x96: three 60 x 36 in sheets, 6 in joins) through panelSheets'
planPanels and screenLayout, the same plan the Bless print uses: the
sheets pulled apart, each join a red glue band with a dashed edge, a
banner on the first sheet, a footer on the last, and rows of portrait
cards inside each sheet's content box, never crossing a join.

The cards are a layout (src/panelsSvg.js): the Bless wall draws its real
cards in its own plugin, which the gallery cannot import. The card is not
offered for printing here.
2026-10-01 08:25:21 +00:00
Doxa Maps
7a66bd9134 i18n posterDesign: the Bless panels note says what is drawn and what is a layout
The Styles tab now draws the Bless wall panels from the shared panel
geometry, so missing.bless no longer says the gallery cannot draw them.
It says the sheets, the glue strips and the card rows come from the
shared geometry, and the cards are shown as a layout because the Bless
wall draws its cards in its own plugin.
2026-10-01 08:25:21 +00:00
Doxa Maps
2d00df7323 Poster Design: Styles draws the prayer packet and every card stock
The seven templates that showed 'preview unavailable' for the packet and
the cards now draw from the shared renderers with the Nepal sample:
- each card stock is the deck the wall prints (createCardRenderer with
  that stock, buildDeck), its first card the thumbnail;
- each packet variant is the packet renderer's page 1, its map page,
  built from the sample's map-only sheet as the wall's packet builds it.

src/pageThumb.js turns a document's first page into an image: it keeps
that page, re-serialises it as XHTML inside an SVG foreignObject and
returns it as an SVG image, which a plain <img> draws. Photos are inlined
only from the page's own origin; the people-group photo host sends no CORS
headers, so a card's photo frame stays empty in the thumbnail (the printed
deck still carries the photo), and no refused fetch reaches the console.

A card stock's print button prints its whole deck. A packet card is not
offered for printing here, since only its page 1 is built.
2026-10-01 08:25:21 +00:00
Doxa Maps
7f4f7cd7bf i18n posterDesign: drop the two 'cannot draw yet' notes the packet and the cards no longer need
The Styles tab now draws the prayer packet's page 1 and every card stock,
so missing.packet and missing.cards are referenced nowhere. Only the notes
for the two templates still not drawn (the Bless panels, the map print)
remain.
2026-10-01 08:24:39 +00:00
Doxa Maps
be6cd3b55b Poster Design: the Sizes tab draws every ladder size to scale; the header's Share is a rounded square
Sizes reads the one shared ladder (POSTER_LADDER in POSTER_LADDER_ORDER)
and feeds each size's sheets straight into the library's PanelSheets, the
component the Wall uses for its 5 x 8 ft preview: multi-sheet sizes show
their sheets pulled apart with the red glue strip. A lineup draws every
size at one scale on one baseline, its scale fitted to the row's width so
a phone keeps every size in view. Each card also states the sheets, the
glued overlap, whether photos fit beside names, and how it prints.

The sheets carry the 3 x 3 ft country poster's layout at that size
(src/layoutSvg.js: header, map, names, table, scripture footer, no words),
labelled as a layout, since the renderer does not draw the large sizes
yet. Every size is sized from panelSheets' own screenLayout, so the fit
computed here is the box PanelSheets renders.

The composite header's Share button takes the header form
(shape="rounded-square"), as Big Picture's top bar does.
2026-10-01 08:24:39 +00:00
Doxa Maps
13e22fe649 i18n posterDesign: the Sizes tab's words, in all 11 locales
The Sizes tab now draws every ladder size, so it needs its own words: the
intro, the to-scale lineup, the paper names (Letter / A4), the dimension
patterns in feet and inches, the per-size rows (sheets, each sheet, glued
overlap, photos beside names, how it prints) and the note that the sheets
show the 3 x 3 layout. The placeholder sentence that said the tab was
waiting for the size table is removed. Only the posterDesign area changes.
2026-10-01 08:24:39 +00:00
Doxa Maps
b462d8d155 Merge remote-tracking branch 'origin/country-posters-plugin/india-whole-2' into poster-design-plugin/b3
# Conflicts:
#	library/packet/poster-render.integrity.js
#	package.json
2026-10-01 08:24:28 +00:00
Doxa Maps
ddefb6f30a Merge remote-tracking branch 'origin/studio/poster-sizes' into poster-design-plugin/b3 2026-10-01 08:23:47 +00:00
29 changed files with 1144 additions and 299 deletions

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "مقاسات الملصقات",
"waiting": "سيظهر سُلّم المقاسات (الورق القياسي، و3 × 3 أقدام، و3 × 5 أقدام، و5.5 × 5.5 أقدام، و5 × 8 أقدام) هنا بعد نشر جدول المقاسات المشترك. سيُعرض كل مقاس كمعاينة طباعة، بالاتجاه الطولي فقط."
"intro": "كل المقاسات التي يمكن طباعة الملصق بها، مرسومة بمقياس رسم. تُطبع المقاسات الكبيرة على عدة أوراق تتداخل على الجدار؛ والشريط الأحمر هو موضع لصق الورقة التالية.",
"lineup": "كل المقاسات جنبًا إلى جنب، بمقياس رسم",
"paper": "ورق طابعة قياسي",
"paperLetter": "ورق Letter الأمريكي، 8.5 × 11 بوصة",
"paperA4": "A4، 210 × 297 مم",
"dimsFt": "{w} × {h} قدم",
"dimsIn": "{w} × {h} بوصة",
"dimsInOne": "{v} بوصة",
"rowSheets": "الأوراق",
"rowSheet": "كل ورقة",
"rowOverlap": "التداخل الملصوق",
"rowPhotos": "صور بجانب الأسماء",
"rowPrint": "طريقة الطباعة",
"yes": "نعم",
"no": "لا",
"printHome": "أي طابعة منزلية",
"printShop": "مطبعة (ملف PDF، صفحة لكل ورقة)",
"layoutNote": "تعرض الأوراق تخطيط ملصق الدولة بمقاس 3 × 3 أقدام في كل مقاس: الترويسة والخريطة والأسماء والجدول وتذييل الآية. سيظهر الملصق المرسوم بكل مقاس في الأنماط عند توفره."
},
"family": {
"mapSheet": "صفحات الخرائط",
@ -30,9 +47,7 @@
"mapPrint": "طباعة الخريطة"
},
"missing": {
"packet": "لا يرسم المعرض الحزمة بعد. تذكر التفاصيل مولّدها ونسختها.",
"cards": "لا يرسم المعرض البطاقات بعد. تذكر التفاصيل المولّد ومقاس البطاقة.",
"bless": "محرّك هذا القالب ليس في المكتبة المشتركة بعد، لذا لا يستطيع المعرض رسمه.",
"bless": "الأوراق وشرائط اللصق ومواضع صفوف البطاقات مأخوذة من هندسة الألواح المشتركة؛ وتُعرض البطاقات كتخطيط، لأن جدار البركة يرسم بطاقاته في إضافته الخاصة.",
"mapPrint": "يطبع هذا القالب الخريطة الحية التي ينظر إليها القارئ، لذا ليست له صورة نموذجية."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Postergrößen",
"waiting": "Die Größenstufen (Standardpapier, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft und 5 × 8 ft) erscheinen hier, sobald die gemeinsame Größentabelle veröffentlicht ist. Jede Größe wird als Druckvorschau gezeigt, nur im Hochformat."
"intro": "Jede Größe, in der ein Poster gedruckt werden kann, maßstabsgetreu gezeichnet. Die großen Größen werden auf mehrere Bögen gedruckt, die sich an der Wand überlappen; der rote Streifen zeigt, wo der nächste Bogen angeklebt wird.",
"lineup": "Alle Größen nebeneinander, maßstabsgetreu",
"paper": "Normales Druckerpapier",
"paperLetter": "US-Letter, 8,5 × 11 in",
"paperA4": "A4, 210 × 297 mm",
"dimsFt": "{w} × {h} ft",
"dimsIn": "{w} × {h} in",
"dimsInOne": "{v} in",
"rowSheets": "Bögen",
"rowSheet": "Jeder Bogen",
"rowOverlap": "Geklebte Überlappung",
"rowPhotos": "Fotos neben den Namen",
"rowPrint": "Druck",
"yes": "Ja",
"no": "Nein",
"printHome": "Jeder Drucker zu Hause",
"printShop": "Eine Druckerei (ein PDF, eine Seite pro Bogen)",
"layoutNote": "Die Bögen zeigen das Layout des 3 × 3 ft Länderposters in jeder Größe: Kopfzeile, Karte, Namen, Tabelle und die Fußzeile mit dem Bibelvers. Das in jeder Größe gezeichnete Poster erscheint unter Stile, sobald es verfügbar ist."
},
"family": {
"mapSheet": "Kartenblätter",
@ -30,9 +47,7 @@
"mapPrint": "Kartendruck"
},
"missing": {
"packet": "Die Galerie zeichnet die Mappe noch nicht. Die Details nennen ihren Generator und ihre Variante.",
"cards": "Die Galerie zeichnet die Karten noch nicht. Die Details nennen den Generator und das Kartenformat.",
"bless": "Die Engine dieser Vorlage ist noch nicht in der gemeinsamen Bibliothek, deshalb kann die Galerie sie nicht zeichnen.",
"bless": "Die Bögen, die Klebestreifen und die Lage der Kartenreihen stammen aus der gemeinsamen Tafelgeometrie; die Karten sind als Layout dargestellt, weil die Segnen-Wand ihre Karten in ihrem eigenen Plugin zeichnet.",
"mapPrint": "Diese Vorlage druckt die Live-Karte, die der Leser gerade ansieht, und hat deshalb kein Beispielbild."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Poster sizes",
"waiting": "The size ladder (standard paper, 3 × 3 ft, 3 × 5 ft, 5.5 × 5.5 ft and 5 × 8 ft) appears here once the shared size table is published. Each size will be shown as a print preview, portrait only."
"intro": "Every size a poster can print at, drawn to scale. The large sizes print on several sheets that overlap on the wall; the red strip is where the next sheet is glued on.",
"lineup": "All sizes side by side, to scale",
"paper": "Standard printer paper",
"paperLetter": "US Letter, 8.5 × 11 in",
"paperA4": "A4, 210 × 297 mm",
"dimsFt": "{w} × {h} ft",
"dimsIn": "{w} × {h} in",
"dimsInOne": "{v} in",
"rowSheets": "Sheets",
"rowSheet": "Each sheet",
"rowOverlap": "Glued overlap",
"rowPhotos": "Photos beside names",
"rowPrint": "How it prints",
"yes": "Yes",
"no": "No",
"printHome": "Any home printer",
"printShop": "A print shop (a PDF, one page per sheet)",
"layoutNote": "The sheets show the layout of the 3 × 3 ft country poster at each size: the header, the map, the names, the table and the scripture footer. The poster drawn at each size appears in Styles as it becomes available."
},
"family": {
"mapSheet": "Map sheets",
@ -30,9 +47,7 @@
"mapPrint": "Map print"
},
"missing": {
"packet": "The gallery does not draw the packet yet. The details name its renderer and variant.",
"cards": "The gallery does not draw the cards yet. The details name the renderer and the card stock.",
"bless": "This template's engine is not in the shared library yet, so the gallery cannot draw it.",
"bless": "The sheets, the glue strips and where the card rows go come from the shared panel geometry; the cards are shown as a layout, because the Bless wall draws its cards in its own plugin.",
"mapPrint": "This template prints the live map the reader is looking at, so it has no sample picture."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Tamaños de póster",
"waiting": "La escala de tamaños (papel estándar, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft y 5 × 8 ft) aparecerá aquí cuando se publique la tabla de tamaños compartida. Cada tamaño se mostrará como vista previa de impresión, solo en vertical."
"intro": "Todos los tamaños en que se puede imprimir un póster, dibujados a escala. Los tamaños grandes se imprimen en varias hojas que se superponen en la pared; la franja roja es donde se pega la hoja siguiente.",
"lineup": "Todos los tamaños lado a lado, a escala",
"paper": "Papel de impresora estándar",
"paperLetter": "Carta (EE. UU.), 8,5 × 11 in",
"paperA4": "A4, 210 × 297 mm",
"dimsFt": "{w} × {h} ft",
"dimsIn": "{w} × {h} in",
"dimsInOne": "{v} in",
"rowSheets": "Hojas",
"rowSheet": "Cada hoja",
"rowOverlap": "Superposición pegada",
"rowPhotos": "Fotos junto a los nombres",
"rowPrint": "Cómo se imprime",
"yes": "Sí",
"no": "No",
"printHome": "Cualquier impresora doméstica",
"printShop": "Una imprenta (un PDF, una página por hoja)",
"layoutNote": "Las hojas muestran el diseño del póster de país de 3 × 3 ft en cada tamaño: el encabezado, el mapa, los nombres, la tabla y el pie con la Escritura. El póster dibujado en cada tamaño aparecerá en Estilos a medida que esté disponible."
},
"family": {
"mapSheet": "Hojas de mapa",
@ -30,9 +47,7 @@
"mapPrint": "Impresión del mapa"
},
"missing": {
"packet": "La galería todavía no dibuja el paquete. Los detalles indican su generador y su variante.",
"cards": "La galería todavía no dibuja las tarjetas. Los detalles indican el generador y el tipo de tarjeta.",
"bless": "El motor de esta plantilla aún no está en la biblioteca compartida, así que la galería no puede dibujarla.",
"bless": "Las hojas, las franjas de pegado y el lugar de las filas de tarjetas vienen de la geometría de paneles compartida; las tarjetas se muestran como un diseño, porque el muro Bendecir dibuja sus tarjetas en su propio complemento.",
"mapPrint": "Esta plantilla imprime el mapa en vivo que está mirando el lector, así que no tiene imagen de ejemplo."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Formats d'affiche",
"waiting": "L'échelle des formats (papier standard, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft et 5 × 8 ft) apparaîtra ici dès que la table des formats partagée sera publiée. Chaque format sera présenté en aperçu avant impression, en portrait uniquement."
"intro": "Tous les formats d'impression d'une affiche, dessinés à l'échelle. Les grands formats s'impriment sur plusieurs feuilles qui se chevauchent au mur ; la bande rouge indique où coller la feuille suivante.",
"lineup": "Tous les formats côte à côte, à l'échelle",
"paper": "Papier d'imprimante standard",
"paperLetter": "Lettre US, 8,5 × 11 in",
"paperA4": "A4, 210 × 297 mm",
"dimsFt": "{w} × {h} ft",
"dimsIn": "{w} × {h} in",
"dimsInOne": "{v} in",
"rowSheets": "Feuilles",
"rowSheet": "Chaque feuille",
"rowOverlap": "Chevauchement collé",
"rowPhotos": "Photos à côté des noms",
"rowPrint": "Impression",
"yes": "Oui",
"no": "Non",
"printHome": "N'importe quelle imprimante",
"printShop": "Un imprimeur (un PDF, une page par feuille)",
"layoutNote": "Les feuilles montrent la mise en page de l'affiche pays de 3 × 3 ft à chaque format : l'en-tête, la carte, les noms, le tableau et le pied avec l'Écriture. L'affiche dessinée à chaque format apparaîtra dans Styles dès qu'elle sera disponible."
},
"family": {
"mapSheet": "Feuilles de carte",
@ -30,9 +47,7 @@
"mapPrint": "Impression de la carte"
},
"missing": {
"packet": "La galerie ne dessine pas encore le dossier. Les détails indiquent son générateur et sa variante.",
"cards": "La galerie ne dessine pas encore les cartes. Les détails indiquent le générateur et le format de carte.",
"bless": "Le moteur de ce modèle n'est pas encore dans la bibliothèque partagée, la galerie ne peut donc pas le dessiner.",
"bless": "Les feuilles, les bandes de collage et l'emplacement des rangées de cartes viennent de la géométrie de panneaux partagée ; les cartes sont montrées comme une mise en page, car le mur Bénir dessine ses cartes dans son propre module.",
"mapPrint": "Ce modèle imprime la carte en direct que le lecteur regarde, il n'a donc pas d'image d'exemple."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "पोस्टर आकार",
"waiting": "साझा आकार तालिका प्रकाशित होते ही आकारों की सीढ़ी (मानक कागज़, 3 × 3 फ़ुट, 3 × 5 फ़ुट, 5.5 × 5.5 फ़ुट और 5 × 8 फ़ुट) यहाँ दिखेगी। हर आकार प्रिंट पूर्वावलोकन के रूप में दिखेगा, केवल खड़े (पोर्ट्रेट) रूप में।"
"intro": "हर वह आकार जिसमें पोस्टर छापा जा सकता है, पैमाने के अनुसार बनाया गया। बड़े आकार कई शीटों पर छपते हैं जो दीवार पर एक-दूसरे पर चढ़ती हैं; लाल पट्टी वह जगह है जहाँ अगली शीट चिपकाई जाती है।",
"lineup": "सभी आकार साथ-साथ, पैमाने के अनुसार",
"paper": "सामान्य प्रिंटर कागज़",
"paperLetter": "यूएस लेटर, 8.5 × 11 इंच",
"paperA4": "A4, 210 × 297 मिमी",
"dimsFt": "{w} × {h} फ़ुट",
"dimsIn": "{w} × {h} इंच",
"dimsInOne": "{v} इंच",
"rowSheets": "शीटें",
"rowSheet": "हर शीट",
"rowOverlap": "चिपकाई जाने वाली ओवरलैप",
"rowPhotos": "नामों के पास फ़ोटो",
"rowPrint": "कैसे छपता है",
"yes": "हाँ",
"no": "नहीं",
"printHome": "कोई भी घरेलू प्रिंटर",
"printShop": "प्रिंटिंग प्रेस (PDF, हर शीट के लिए एक पेज)",
"layoutNote": "शीटें हर आकार में 3 × 3 फ़ुट देश पोस्टर का लेआउट दिखाती हैं: शीर्षक, मानचित्र, नाम, तालिका और पवित्रशास्त्र वाला फ़ुटर। हर आकार में बना असली पोस्टर उपलब्ध होते ही शैलियाँ में दिखेगा।"
},
"family": {
"mapSheet": "मानचित्र पृष्ठ",
@ -30,9 +47,7 @@
"mapPrint": "मानचित्र प्रिंट"
},
"missing": {
"packet": "गैलरी अभी पैकेट नहीं बनाती। विवरण में उसका जनरेटर और संस्करण बताया गया है।",
"cards": "गैलरी अभी कार्ड नहीं बनाती। विवरण में जनरेटर और कार्ड का आकार बताया गया है।",
"bless": "इस टेम्पलेट का इंजन अभी साझा लाइब्रेरी में नहीं है, इसलिए गैलरी इसे नहीं बना सकती।",
"bless": "शीटें, चिपकाने की पट्टियाँ और कार्ड-पंक्तियों की जगह साझा पैनल ज्यामिति से आती हैं; कार्ड लेआउट के रूप में दिखाए गए हैं, क्योंकि आशीष दीवार अपने कार्ड अपने प्लगइन में बनाती है।",
"mapPrint": "यह टेम्पलेट वह लाइव मानचित्र प्रिंट करता है जिसे पाठक देख रहा है, इसलिए इसकी कोई नमूना तस्वीर नहीं है।"
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Formati dei poster",
"waiting": "La scala dei formati (carta standard, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft e 5 × 8 ft) comparirà qui quando sarà pubblicata la tabella dei formati condivisa. Ogni formato sarà mostrato come anteprima di stampa, solo in verticale."
"intro": "Tutti i formati in cui si può stampare un poster, disegnati in scala. I formati grandi si stampano su più fogli che si sovrappongono sulla parete; la striscia rossa indica dove incollare il foglio successivo.",
"lineup": "Tutti i formati affiancati, in scala",
"paper": "Carta da stampante standard",
"paperLetter": "Letter USA, 8,5 × 11 in",
"paperA4": "A4, 210 × 297 mm",
"dimsFt": "{w} × {h} ft",
"dimsIn": "{w} × {h} in",
"dimsInOne": "{v} in",
"rowSheets": "Fogli",
"rowSheet": "Ogni foglio",
"rowOverlap": "Sovrapposizione incollata",
"rowPhotos": "Foto accanto ai nomi",
"rowPrint": "Come si stampa",
"yes": "Sì",
"no": "No",
"printHome": "Qualsiasi stampante di casa",
"printShop": "Una tipografia (un PDF, una pagina per foglio)",
"layoutNote": "I fogli mostrano l'impaginazione del poster di paese da 3 × 3 ft in ogni formato: intestazione, mappa, nomi, tabella e il piè di pagina con la Scrittura. Il poster disegnato in ogni formato comparirà in Stili appena disponibile."
},
"family": {
"mapSheet": "Fogli mappa",
@ -30,9 +47,7 @@
"mapPrint": "Stampa della mappa"
},
"missing": {
"packet": "La galleria non disegna ancora il fascicolo. I dettagli indicano il suo generatore e la variante.",
"cards": "La galleria non disegna ancora le schede. I dettagli indicano il generatore e il formato della scheda.",
"bless": "Il motore di questo modello non è ancora nella libreria condivisa, quindi la galleria non può disegnarlo.",
"bless": "I fogli, le strisce da incollare e la posizione delle file di schede vengono dalla geometria dei pannelli condivisa; le schede sono mostrate come impaginazione, perché il muro Benedici disegna le sue schede nel proprio plugin.",
"mapPrint": "Questo modello stampa la mappa dal vivo che il lettore sta guardando, quindi non ha un'immagine di esempio."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Tamanhos de pôster",
"waiting": "A escala de tamanhos (papel padrão, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft e 5 × 8 ft) aparecerá aqui quando a tabela de tamanhos compartilhada for publicada. Cada tamanho será mostrado como pré-visualização de impressão, apenas na vertical."
"intro": "Todos os tamanhos em que um pôster pode ser impresso, desenhados em escala. Os tamanhos grandes são impressos em várias folhas que se sobrepõem na parede; a faixa vermelha mostra onde colar a folha seguinte.",
"lineup": "Todos os tamanhos lado a lado, em escala",
"paper": "Papel de impressora padrão",
"paperLetter": "Carta (EUA), 8,5 × 11 in",
"paperA4": "A4, 210 × 297 mm",
"dimsFt": "{w} × {h} ft",
"dimsIn": "{w} × {h} in",
"dimsInOne": "{v} in",
"rowSheets": "Folhas",
"rowSheet": "Cada folha",
"rowOverlap": "Sobreposição colada",
"rowPhotos": "Fotos ao lado dos nomes",
"rowPrint": "Como se imprime",
"yes": "Sim",
"no": "Não",
"printHome": "Qualquer impressora doméstica",
"printShop": "Uma gráfica (um PDF, uma página por folha)",
"layoutNote": "As folhas mostram o layout do pôster de país de 3 × 3 ft em cada tamanho: o cabeçalho, o mapa, os nomes, a tabela e o rodapé com a Escritura. O pôster desenhado em cada tamanho aparecerá em Estilos quando estiver disponível."
},
"family": {
"mapSheet": "Folhas de mapa",
@ -30,9 +47,7 @@
"mapPrint": "Impressão do mapa"
},
"missing": {
"packet": "A galeria ainda não desenha o pacote. Os detalhes indicam o seu gerador e a variante.",
"cards": "A galeria ainda não desenha os cartões. Os detalhes indicam o gerador e o formato do cartão.",
"bless": "O motor deste modelo ainda não está na biblioteca compartilhada, por isso a galeria não consegue desenhá-lo.",
"bless": "As folhas, as faixas de colagem e a posição das fileiras de cartões vêm da geometria de painéis compartilhada; os cartões aparecem como um layout, porque o mural Abençoar desenha os seus cartões no seu próprio plugin.",
"mapPrint": "Este modelo imprime o mapa ao vivo que o leitor está vendo, por isso não tem imagem de exemplo."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Dimensiuni de afiș",
"waiting": "Scara dimensiunilor (hârtie standard, 3 × 3 ft, 3 × 5 ft, 5,5 × 5,5 ft și 5 × 8 ft) va apărea aici după publicarea tabelului comun de dimensiuni. Fiecare dimensiune va fi afișată ca previzualizare de tipărire, doar pe verticală."
"intro": "Toate dimensiunile la care se poate tipări un afiș, desenate la scară. Dimensiunile mari se tipăresc pe mai multe coli care se suprapun pe perete; banda roșie arată unde se lipește coala următoare.",
"lineup": "Toate dimensiunile alăturate, la scară",
"paper": "Hârtie standard de imprimantă",
"paperLetter": "Letter SUA, 8,5 × 11 in",
"paperA4": "A4, 210 × 297 mm",
"dimsFt": "{w} × {h} ft",
"dimsIn": "{w} × {h} in",
"dimsInOne": "{v} in",
"rowSheets": "Coli",
"rowSheet": "Fiecare coală",
"rowOverlap": "Suprapunere lipită",
"rowPhotos": "Fotografii lângă nume",
"rowPrint": "Cum se tipărește",
"yes": "Da",
"no": "Nu",
"printHome": "Orice imprimantă de acasă",
"printShop": "O tipografie (un PDF, o pagină pe coală)",
"layoutNote": "Colile arată machetarea afișului de țară de 3 × 3 ft la fiecare dimensiune: antetul, harta, numele, tabelul și subsolul cu versetul din Scriptură. Afișul desenat la fiecare dimensiune va apărea în Stiluri când va fi disponibil."
},
"family": {
"mapSheet": "Foi de hartă",
@ -30,9 +47,7 @@
"mapPrint": "Tipărirea hărții"
},
"missing": {
"packet": "Galeria nu desenează încă pachetul. Detaliile numesc generatorul și varianta lui.",
"cards": "Galeria nu desenează încă cartonașele. Detaliile numesc generatorul și formatul cartonașului.",
"bless": "Motorul acestui șablon nu este încă în biblioteca comună, așa că galeria nu îl poate desena.",
"bless": "Colile, benzile de lipire și locul rândurilor de cartonașe vin din geometria comună a panourilor; cartonașele sunt arătate ca machetă, pentru că peretele Binecuvântează își desenează cartonașele în propriul modul.",
"mapPrint": "Acest șablon tipărește harta live pe care o privește cititorul, așa că nu are imagine de exemplu."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "Размеры плакатов",
"waiting": "Шкала размеров (стандартная бумага, 3 × 3 фута, 3 × 5 футов, 5,5 × 5,5 фута и 5 × 8 футов) появится здесь, когда будет опубликована общая таблица размеров. Каждый размер будет показан как предпросмотр печати, только в книжной ориентации."
"intro": "Все размеры, в которых можно напечатать плакат, в масштабе. Большие размеры печатаются на нескольких листах, которые на стене заходят друг на друга; красная полоса показывает, где приклеить следующий лист.",
"lineup": "Все размеры рядом, в масштабе",
"paper": "Обычная бумага для принтера",
"paperLetter": "US Letter, 8,5 × 11 дюймов",
"paperA4": "A4, 210 × 297 мм",
"dimsFt": "{w} × {h} фута",
"dimsIn": "{w} × {h} дюймов",
"dimsInOne": "{v} дюймов",
"rowSheets": "Листы",
"rowSheet": "Каждый лист",
"rowOverlap": "Склеиваемый нахлёст",
"rowPhotos": "Фото рядом с названиями",
"rowPrint": "Как печатать",
"yes": "Да",
"no": "Нет",
"printHome": "Любой домашний принтер",
"printShop": "Типография (PDF, одна страница на лист)",
"layoutNote": "На листах показан макет плаката страны 3 × 3 фута в каждом размере: заголовок, карта, названия, таблица и нижняя полоса со стихом из Писания. Плакат, нарисованный в каждом размере, появится в разделе «Стили», когда будет готов."
},
"family": {
"mapSheet": "Листы карт",
@ -30,9 +47,7 @@
"mapPrint": "Печать карты"
},
"missing": {
"packet": "Галерея пока не рисует пакет. В сведениях указаны его генератор и вариант.",
"cards": "Галерея пока не рисует карточки. В сведениях указаны генератор и формат карточки.",
"bless": "Движка этого шаблона пока нет в общей библиотеке, поэтому галерея не может его нарисовать.",
"bless": "Листы, полосы для склейки и места рядов карточек взяты из общей геометрии панелей; карточки показаны как макет, потому что стена «Благословить» рисует свои карточки в собственном плагине.",
"mapPrint": "Этот шаблон печатает живую карту, на которую смотрит читатель, поэтому у него нет примерного изображения."
},
"details": {

View file

@ -11,7 +11,24 @@
},
"sizes": {
"heading": "海报尺寸",
"waiting": "共享尺寸表发布后,尺寸阶梯(标准纸张、3 × 3 英尺、3 × 5 英尺、5.5 × 5.5 英尺和 5 × 8 英尺)将显示在这里。每种尺寸都会以打印预览呈现,仅限纵向。"
"intro": "海报可打印的所有尺寸,按比例绘制。大尺寸海报分多张纸打印,贴在墙上时彼此重叠;红色条带标出下一张纸的粘贴位置。",
"lineup": "所有尺寸并排,按比例",
"paper": "标准打印纸",
"paperLetter": "美国信纸,8.5 × 11 英寸",
"paperA4": "A4,210 × 297 毫米",
"dimsFt": "{w} × {h} 英尺",
"dimsIn": "{w} × {h} 英寸",
"dimsInOne": "{v} 英寸",
"rowSheets": "纸张数",
"rowSheet": "每张纸",
"rowOverlap": "粘贴重叠",
"rowPhotos": "名称旁配照片",
"rowPrint": "打印方式",
"yes": "是",
"no": "否",
"printHome": "任何家用打印机",
"printShop": "印刷店(PDF,每张纸一页)",
"layoutNote": "纸张显示的是 3 × 3 英尺国家海报在各尺寸下的版式:标题、地图、名称、表格和经文页脚。各尺寸实际绘制的海报完成后会出现在“样式”中。"
},
"family": {
"mapSheet": "地图页",
@ -30,9 +47,7 @@
"mapPrint": "地图打印"
},
"missing": {
"packet": "图库暂不绘制资料包。详情中列出了它的生成器和版本。",
"cards": "图库暂不绘制祷告卡。详情中列出了生成器和卡片规格。",
"bless": "此模板的引擎尚未进入共享组件库,因此图库无法绘制。",
"bless": "纸张、粘贴条以及卡片行的位置都来自共享的拼板几何;卡片以版式示意,因为祝福墙在其自己的插件中绘制卡片。",
"mapPrint": "此模板打印读者正在查看的实时地图,因此没有示例图片。"
},
"details": {

View file

@ -349,7 +349,7 @@
"fix:sri": "node internal/fix-sri.mjs",
"gallery": "node internal/generate-gallery.mjs",
"gates:post": "node internal/run-parallel.mjs --label=post -- check:capability-derived check:drop check:parse verify check:sri check:public-hygiene check:label-joins check:facet-cascade check:facet-one-field check:heartbeat check:kpi-unit check:composite-reg check:descriptions check:no-abs-paths check:no-codename check:dep-names check:composite-srcs check:embed-targets check:nested-token check:i18n-parity check:tabs-localized check:packets-all check:packet-strings-parity check:zone-census check:zoning-declared check:share-policy check:library-index check:csv-shape check:capability-citations check:catalog-identity check:no-orphan-gates check:quoted-scripts check:script-flags check:capability-exemplars check:packet-fixture check:packet-browser check:provider-switch check:locale-reachable check:url-resolver check:manifest-covers check:one-palette check:pin-dim-slider check:legend-source check:clustering-modes check:composite-embed-knobs check:composite-locale-leaks check:linked-vocabulary check:wp-embed check:dashboard-geocoder-provider check:leaf-engine-per-provider check:auto-engine check:engine-contract check:map-props-unit check:composites-mount check:composite-interop check:no-free-identifiers check:map-fly-reduced-motion check:mst-clustering check:list-prop-unit check:profiles-mount check:cdn-pins check:no-eval check:popup-escaping check:i18n-treeshake check:pinned-umd check:one-palette:self-test check:no-cross-plugin-imports check:no-cross-plugin-imports:self-test check:map-attribution check:studio-rail check:approx-pin-note check:embedder-keys check:provider-for check:legend-logical-insets check:legend-logical-insets:self-test check:optional-t-equivalence check:text-highlight check:religion-label-not-value check:i18n-stopped-scope check:no-silent-engine check:no-silent-engine:self-test check:regions-uses-library check:shadow-css-parses check:square-map-controls check:preset-config-merge check:instance-config-keys check:frozen-labels check:plural-forms check:region-fill-repaint check:region-paint-single-source check:poster-print-fallback check:posters-exportable check:country-names check:reduced-motion check:no-dead-library check:universal-props check:readme-size check:tree-relative check:leaf-hints check:snippet-engine check:snippet-engine:self-test check:embed-base check:link-coverage check:no-public-binds check:shared-reached check:tree-weight check:no-fleet-names:self-test check:pin-hint-floor:self-test check:poster-sizes:self-test",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:side-lanes check:side-lanes:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes",
"gates:pre": "node internal/run-parallel.mjs --label=pre -- check:template-literal check:mjs-parse check:sfc-parse check:tab-radius check:synced-css check:doc-scripts check:setup-order check:package-json-unique-keys check:no-repo-pb-hooks check:no-credentials-omit check:no-credentials-omit:self-test check:shadow-reduced-motion check:poster-action-print check:targets check:targets:self-test check:browser-gates-guarded check:pin-selection check:facet-shape check:hardcoded-english:self-test check:list-selection check:selector-fields check:upg-zone-joins check:gospel-set-charts check:gospel-set-fit check:describe-group check:one-scroll-container check:packet-charts check:composite-token-url check:language-tree-layout check:i18n-keys-exist check:i18n-never-translated check:packet-variants check:profile-table check:element-bootstrap check:embed-url-absolute check:frame-target check:language-tree-model check:map-menu-anchor check:selection-kinds check:tiered-points check:whiteboard-projection check:mercator-slice check:ring-vocabulary check:map-props-unit check:mobile-gating check:provide-shadowing check:legend-top-offset check:cdn-cache check:plural-forms check:select-zone-race check:script-registry check:map-control-overlap check:poster-attribution check:board-map-options check:gospel-whiteboard check:packet-map-missing-loud check:poster-layout-optin check:ported-globals-identical check:ring-bands check:packet-a1-structure check:india-states check:batch-selection-export check:zone-title-tier-a check:evangelical-share check:map-labels check:packet-offline check:help-reachable check:gated-not-in-tree check:window-globals-released check:legend-data-contract check:no-internal-role-nouns check:poster-fetch-guard check:people-group-records check:sentence-slots check:popup-href-escaped check:popup-pan-controls check:country-map-refetch check:pane-config-reforward check:viewport-meta check:viewport-meta:self-test check:label-key-coverage check:poster-browser-equiv check:poster-render-pinned check:country-sheet-one-path check:country-sheet-one-path:self-test check:posterverse-shelf check:host-locale-fetch check:print-frame-reclaim check:click-sheet check:coverage-consistent:chained check:coverage-consistent:poison check:dashboard-filter-a11y check:filter-multivalue check:hover-channel check:kit-options-reachable check:layout-key-namespaced check:legend-mirrors check:map-unmount-race check:no-conflict-markers check:series-isolation check:snapshot-guard check:stale-sweep-claim check:surfaces-read-the-tier check:translator-contacts check:wall-wysiwyg check:wall-print-watch check:wall-no-bg-photos check:wall-pan check:template-i18n check:list-pattern-doc check:shadow-style-labels-unique check:grid-image-loading check:retry-fetch check:panel-sheets check:poster-wall-host-seams check:poster-design-catalogue check:wall-photo-weight check:no-fleet-names check:pin-hint-floor check:poster-sizes check:side-lanes check:side-lanes:self-test",
"gen:iso3-to-alpha2": "node library/scripts/generate-iso3-to-alpha2.mjs",
"internet:adm1": "node plugin-designs/cities-internet/scripts/build-adm1-internet.mjs",
"library-index": "node internal/generate-library-index.mjs",

View file

@ -117,7 +117,8 @@ useShadowStyles(PANE_GRID_STYLES + `
<div class="pdmfe">
<header class="hdr">
<ViewSelect :views="spec.panes" :current="view" :legend="t('tabs.selectView')" :label="t('tabs.views')" @select="go" />
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" />
<!-- A HEADER button, so the header form: a rounded square (Big Picture's DashboardTopBar uses the same). -->
<ShareButton :is-dark="isDark" :embed-path="SHARE_EMBED_PATH" shape="rounded-square" />
</header>
<div class="stage" ref="stage"></div>
</div>

View file

@ -1,35 +1,164 @@
<script setup>
/**
* poster-sizes.vue: every poster size, each card a print preview at that size.
* poster-sizes.vue: every poster size, drawn to scale, with the sheets each one prints on.
*
* @element poster-design
* @description Every poster size the print picker offers (standard paper up to 5 × 8 ft), each drawn as a
* print preview, portrait only, from the one shared size table.
* @description Every poster size the print picker offers, from standard paper to 5 × 8 ft, drawn to scale.
* A size that prints on several sheets shows them pulled apart with the red strip where the next sheet is
* glued on, as the Wall of People Groups previews its 5 × 8 ft poster.
*
* The sizes come from the shared size table in library/constants/posterSizes.js and nowhere else: a
* second list here would drift from the print picker. Until that table carries the size ladder, this
* tab says so rather than inventing one.
* The sizes are the ONE shared ladder (library/constants/posterSizes.js POSTER_LADDER, in
* POSTER_LADDER_ORDER), never a second list: a size the ladder gains appears here by itself. Each size's
* `sheets` goes straight into the library's PanelSheets, the same component and the same geometry
* (panelSheets.js) the print uses. What fills the sheets is the 3 × 3 country poster's LAYOUT drawn at that
* size (src/layoutSvg.js), labelled as a layout, because the renderer does not draw the large sizes yet.
*/
import { computed } from 'vue'
import { useOptionalT } from '@map/composables/useOptionalT.js'
import { computed, ref, onMounted, onBeforeUnmount } from 'vue'
import PanelSheets from '@map/components/poster/PanelSheets.vue'
import { planPanels, screenLayout } from '@map/components/poster/panelSheets.js'
import { POSTER_LADDER, POSTER_LADDER_ORDER, paperFor } from '@map/constants/posterSizes.js'
import { useOptionalT, useOptionalI18n } from '@map/composables/useOptionalT.js'
import { useShadowStyles } from '@map/composables/useShadowStyles.js'
import { layoutSvgUrl } from './src/layoutSvg.js'
const t = useOptionalT('')
const heading = computed(() => t('posterDesign.sizes.heading'))
const waiting = computed(() => t('posterDesign.sizes.waiting'))
const { locale } = useOptionalI18n('')
// A number as the sizes are spoken: whole, or one decimal place (5.5).
const n = (x) => (Number.isInteger(x) ? String(x) : String(Math.round(x * 10) / 10))
// Feet once a side reaches a yard and both sides are whole or half feet (3 × 5.5 ft); inches below that (18 × 24 in).
function dims(w, h) {
const ft = (x) => x / 12
const halfFeet = (x) => Number.isInteger(ft(x) * 2)
if (Math.min(w, h) >= 36 && halfFeet(w) && halfFeet(h)) return t('posterDesign.sizes.dimsFt', { w: n(ft(w)), h: n(ft(h)) })
return t('posterDesign.sizes.dimsIn', { w: n(w), h: n(h) })
}
// Standard printer paper is Letter or A4 by the reader's region, as the print picker decides it.
const paperIsA4 = computed(() => {
const l = locale && (locale.value ?? locale)
const tag = (typeof navigator !== 'undefined' && navigator.language) || (typeof l === 'string' ? l : '') || 'en'
return paperFor(tag) === 'a4'
})
const sizes = computed(() => POSTER_LADDER_ORDER.map((key) => POSTER_LADDER[key]).filter(Boolean).map((s) => {
const { sheetW, sheetH, count, direction, joinIn } = s.sheets
return {
key: s.id,
w: s.inches.w,
h: s.inches.h,
sheets: { sheetW, sheetH, count, direction, joinIn },
title: s.paper ? t('posterDesign.sizes.paper') : dims(s.inches.w, s.inches.h),
subtitle: s.paper ? t(paperIsA4.value ? 'posterDesign.sizes.paperA4' : 'posterDesign.sizes.paperLetter') : '',
count,
sheetDims: dims(sheetW, sheetH),
overlap: count > 1 ? t('posterDesign.sizes.dimsInOne', { v: n(joinIn) }) : '',
photos: !!s.photos,
shop: s.path === 'pdf',
src: layoutSvgUrl(s.inches.w, s.inches.h),
}
}))
// The box PanelSheets draws, in inches: its own screenLayout of the same plan, so a fit computed here is the fit
// it renders (each sheet whole, pulled apart by the gap, which is taller than the finished poster).
const LINEUP_GAP_IN = 1.5
const CARD_GAP_IN = 0.75
function world(s, gapIn) {
const L = screenLayout(planPanels(s.sheets), { gapIn, pxPerIn: 1 })
return { w: L.worldW, h: L.worldH }
}
// THE LINEUP: every size at ONE scale, standing on one baseline, so 5 × 8 ft looks as big beside paper as it
// is. Each size gets an equal column; the scale is the largest at which the tallest drawing is LINEUP_H and
// the widest still fits its column, so on a phone every size stays in view and stays to scale.
const LINEUP_H = 240 // px for the tallest size, when there is room
const COL_GAP = 12
const rowEl = ref(null)
const rowW = ref(1000)
let ro = null
onMounted(() => {
if (!rowEl.value || typeof ResizeObserver === 'undefined') return
ro = new ResizeObserver(([e]) => { if (e && e.contentRect.width) rowW.value = e.contentRect.width })
ro.observe(rowEl.value)
})
onBeforeUnmount(() => { ro?.disconnect(); ro = null })
const pxPerIn = computed(() => {
const ws = sizes.value.map((s) => world(s, LINEUP_GAP_IN))
if (!ws.length) return 1
const col = (rowW.value - COL_GAP * (ws.length - 1)) / ws.length
return Math.min(LINEUP_H / Math.max(...ws.map((w) => w.h)), col / Math.max(...ws.map((w) => w.w)))
})
const lineupW = (s) => `${Math.max(4, world(s, LINEUP_GAP_IN).w * pxPerIn.value)}px`
useShadowStyles(`
:host{display:block;width:100%;height:100%;min-height:420px}
.pd-sizes{position:absolute;inset:0;overflow-y:auto;padding:28px 20px;background:#f3f3f1;color:#3b463d;
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif}
.pd-sizes__h{margin:0 0 8px;font-size:22px;font-weight:700}
.pd-sizes__note{margin:0;max-width:60ch;color:#6d766e}
.pd-sizes{position:absolute;inset:0;overflow-y:auto;padding:24px 20px 40px;background:#f3f3f1;color:#3b463d;
font:14px/1.5 -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,system-ui,sans-serif;box-sizing:border-box}
.pd-sizes__h{margin:0 0 6px;font-size:22px;font-weight:700}
.pd-sizes__intro{margin:0 0 18px;max-width:70ch;color:#6d766e}
.pd-lineup{background:#fff;border:1px solid #d1d4cd;border-radius:12px;padding:16px 16px 10px;margin:0 0 24px}
.pd-lineup__h{margin:0 0 12px;font-size:14px;font-weight:700;color:#6d766e}
.pd-lineup__row{display:grid;grid-auto-flow:column;grid-auto-columns:minmax(0,1fr);align-items:end;gap:12px;
padding-bottom:6px;border-bottom:2px solid #d1d4cd}
.pd-lineup__item{display:flex;flex-direction:column;align-items:center;gap:6px;min-width:0}
.pd-lineup__label{font-size:12px;font-weight:600;text-align:center;line-height:1.25;overflow-wrap:anywhere}
.pd-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:18px}
.pd-card{background:#fff;border:1px solid #d1d4cd;border-radius:12px;padding:14px;display:flex;flex-direction:column;gap:10px;min-width:0}
.pd-card__art{display:flex;justify-content:center;align-items:flex-end;height:220px;background:#f3f3f1;border-radius:8px;padding:10px;box-sizing:border-box}
.pd-card__art > .pd-card__fit{height:100%;display:flex;align-items:flex-end;justify-content:center;width:100%}
.pd-card__title{margin:0;font-size:17px;font-weight:700}
.pd-card__sub{margin:-8px 0 0;color:#6d766e;font-size:13px}
.pd-card__rows{display:grid;grid-template-columns:max-content 1fr;gap:4px 12px;margin:0;font-size:13px}
.pd-card__rows dt{color:#6d766e}
.pd-card__rows dd{margin:0}
.pd-note{margin:20px 0 0;max-width:70ch;color:#6d766e;font-size:13px}
`, 'poster-design-sizes')
// A card's art box leaves 200 × 222 px for the drawing; it fits by whichever side binds first.
const ART_H = 200
const ART_W = 222
function artWidth(s) {
const { w, h } = world(s, CARD_GAP_IN)
return `${Math.floor(w * Math.min(ART_H / h, ART_W / w))}px`
}
</script>
<template>
<div class="pd-sizes">
<h2 class="pd-sizes__h">{{ heading }}</h2>
<p class="pd-sizes__note">{{ waiting }}</p>
<h2 class="pd-sizes__h">{{ t('posterDesign.sizes.heading') }}</h2>
<p class="pd-sizes__intro">{{ t('posterDesign.sizes.intro') }}</p>
<section class="pd-lineup" :aria-label="t('posterDesign.sizes.lineup')">
<h3 class="pd-lineup__h">{{ t('posterDesign.sizes.lineup') }}</h3>
<div ref="rowEl" class="pd-lineup__row">
<div v-for="s in sizes" :key="s.key" class="pd-lineup__item">
<div :style="{ width: lineupW(s) }">
<PanelSheets v-bind="s.sheets" :gap-in="LINEUP_GAP_IN" :src="s.src" />
</div>
<span class="pd-lineup__label">{{ s.title }}</span>
</div>
</div>
</section>
<div class="pd-grid">
<article v-for="s in sizes" :key="s.key" class="pd-card" :data-size="s.key">
<div class="pd-card__art">
<div class="pd-card__fit">
<div :style="{ width: artWidth(s) }"><PanelSheets v-bind="s.sheets" :gap-in="CARD_GAP_IN" :src="s.src" /></div>
</div>
</div>
<h3 class="pd-card__title">{{ s.title }}</h3>
<p v-if="s.subtitle" class="pd-card__sub">{{ s.subtitle }}</p>
<dl class="pd-card__rows">
<dt>{{ t('posterDesign.sizes.rowSheets') }}</dt><dd>{{ s.count }}</dd>
<template v-if="s.count > 1">
<dt>{{ t('posterDesign.sizes.rowSheet') }}</dt><dd>{{ s.sheetDims }}</dd>
<dt>{{ t('posterDesign.sizes.rowOverlap') }}</dt><dd>{{ s.overlap }}</dd>
</template>
<dt>{{ t('posterDesign.sizes.rowPhotos') }}</dt><dd>{{ s.photos ? t('posterDesign.sizes.yes') : t('posterDesign.sizes.no') }}</dd>
<dt>{{ t('posterDesign.sizes.rowPrint') }}</dt><dd>{{ s.shop ? t('posterDesign.sizes.printShop') : t('posterDesign.sizes.printHome') }}</dd>
</dl>
</article>
</div>
<p class="pd-note">{{ t('posterDesign.sizes.layoutNote') }}</p>
</div>
</template>

View file

@ -23,6 +23,10 @@ import { getApiBaseUrl } from '@map/api/apiBaseUrl.js'
import { getRuntimeLocale, detectLocale } from '@map/i18n/index.js'
import countryData from '@map/data/country-posters.json'
import { makeCountrySheet } from '@map/packet/countrySheet.js'
import { loadPacketStrings } from '@map/packet/packetStrings.js'
import { firstPageImage } from './src/pageThumb.js'
import { panelsSvgUrl } from './src/panelsSvg.js'
import { resolvePosterSize } from '@map/constants/posterSizes.js'
import { listConfigurations, FAMILY_KEYS, SAMPLE_COUNTRY_ID } from './src/templates.js'
import FullDetailsDialog from './src/FullDetailsDialog.vue'
@ -64,33 +68,80 @@ const countrySheet = (format, id = SAMPLE_COUNTRY_ID) => {
const c = countryById(id)
return c ? sheetFor(c, format) : Promise.resolve('')
}
const ctx = { countrySheet }
// The sample's people groups and the packet's words, each fetched once for the whole gallery.
const lang = () => getRuntimeLocale() || detectLocale() || 'en'
let rowsP = null
const sampleRows = () => (rowsP ||= (sample ? recordsFor({ type: 'country', values: [sample.iso3] }) : Promise.resolve([]))
.then((r) => r || []).catch(() => { rowsP = null; return [] }))
let stringsP = null
const packetStrings = () => (stringsP ||= loadPacketStrings(assetsBase.value))
// A card deck exactly as the wall prints one (usePosterPrints printCardDeck: createCardRenderer(...).buildDeck):
// the whole deck is the document, its first card the thumbnail.
async function cardDeck(stockId) {
const [rows, strings, { createCardRenderer }] = await Promise.all([sampleRows(), packetStrings(), import('@map/packet/card-render.js')])
if (!rows.length) return null
const R = createCardRenderer({ strings, lang: lang(), stock: stockId })
return { html: R.buildDeck(sample, rows), page: '.card', width: R.W, height: R.H }
}
// A packet's PAGE 1, its map page, from the same renderer and the same map sheet the wall's packet uses
// (the sample's map-only 'packet' sheet, lifted by the renderer). Only page 1 is built: it is what the
// thumbnail shows, and it needs nothing the later pages need (descriptions, the evangelical share).
async function packetDocument(variant) {
const [rows, strings, { createPacketRenderer }, sheetHtml] = await Promise.all([
sampleRows(), packetStrings(), import('@map/packet/packet-render.js'), countrySheet('packet'),
])
if (!rows.length) return null
const R = createPacketRenderer({ strings, lang: lang() })
const mapSvg = sheetHtml ? R.liftMap(sheetHtml) : ''
const doc = R.buildPacket(sample, rows, new Map(), mapSvg, null, null, variant, null)
return { html: doc, page: '.sheet', width: R.W, height: R.H, firstPageOnly: true }
}
// A multi-sheet poster as its panels, from the shared ladder's sheets for that size.
function panels(sizeKey) {
const size = resolvePosterSize(sizeKey)
return size ? { image: panelsSvgUrl(size.sheets) } : null
}
const ctx = { countrySheet, cardDeck, packetDocument, panels }
// The printed document's size, measured when it is drawn: what "file size" means in the details.
// render() answers a poster's HTML (a map sheet) or a document { html, page, width, height } (a deck, a packet).
const fileBytes = ref({})
async function renderDocument(name) {
const c = byName.get(name)
if (!c || !c.render) return ''
const html = await c.render(ctx)
if (html) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
return html || ''
if (!c || !c.render) return null
const out = await c.render(ctx)
if (out && out.image) return out
const html = typeof out === 'string' ? out : out && out.html
if (!html) return null
if (!(out && out.firstPageOnly)) fileBytes.value = { ...fileBytes.value, [name]: new Blob([html]).size }
return out
}
// previewSrc: the thumbnail, rasterised from the drawn document at a quarter scale (a 1500px sheet
// becomes ~375px, the card's size). A render that comes out blank is refused, so the card shows
// "preview unavailable" rather than a black rectangle.
// previewSrc: the thumbnail. A map sheet is rasterised at a quarter scale (a 1500px sheet becomes ~375px,
// the card's size), and a render that comes out blank is refused, so the card shows "preview unavailable"
// rather than a black rectangle. A document's first page is drawn as it prints (src/pageThumb.js).
async function previewSrc(item) {
const html = await renderDocument(item.id)
if (!html) return null
const out = await renderDocument(item.id)
if (!out) return null
if (out.image) return out.image
if (typeof out === 'object') return firstPageImage(out.html, out)
const { rasterPoster, looksRendered } = await import('@map/packet/poster-raster.js')
const blob = await rasterPoster(html, { scale: 0.25 })
const blob = await rasterPoster(out, { scale: 0.25 })
if (!(await looksRendered(blob))) return null
return URL.createObjectURL(blob)
}
// The card's print button prints THIS configuration: the format the wall asks for is ignored on
// purpose, because the card already names its size.
const sheet = (item) => renderDocument(item.id)
// The card's print button prints THIS configuration: the format the wall asks for is ignored on purpose,
// because the card already names its size. A card deck prints whole, as the wall prints it. A packet is
// built here only as far as its page 1, so it is NOT offered for printing from this card: printing a
// packet missing its later pages would misrepresent the template.
async function sheet(item) {
const out = await renderDocument(item.id)
if (!out || out.image || (typeof out === 'object' && out.firstPageOnly)) return ''
return typeof out === 'string' ? out : out.html
}
// ── "Show full details" ───────────────────────────────────────────────────────────────────────
const open = ref(null)

View file

@ -0,0 +1,55 @@
/**
* layoutSvg.js: the LAYOUT of the 3 × 3 country poster (a header band, the map, the names down both sides,
* a table beneath, and the bold scripture footer), drawn at any finished size as an SVG image.
*
* The Sizes tab shows how each ladder size prints before the renderer draws that size for real, so this is
* a layout, not a poster: blocks in the poster's own colours, no words (nothing to translate, nothing that
* reads as real data). Units are INCHES (the viewBox is the finished w × h), so PanelSheets can slice it
* across sheets exactly as it slices a real poster.
*/
const PAPER = '#f7f4ec'
const INK = '#3b463d'
const LINE = '#c9c3b3'
const LAND = '#dcd3ba'
const SEA = '#e6eef0'
const ACCENT = '#92b195'
/** @returns {string} an image URL (a data: URL of an SVG) of the layout at w × h inches. */
export function layoutSvgUrl(w, h) {
const m = Math.min(w, h) * 0.05 // outer margin
const headH = h * 0.09
const footH = h * 0.08
const bodyTop = m + headH + m * 0.6
const bodyBottom = h - m - footH - m * 0.6
const tableH = (bodyBottom - bodyTop) * 0.22
const mapBottom = bodyBottom - tableH - m * 0.5
const colW = (w - 2 * m) * 0.22 // a name column on each side of the map
const mapX = m + colW + m * 0.4
const mapW = w - 2 * mapX
const lineGap = Math.max(h / 80, 0.35)
const nameLines = (x) => {
let out = ''
for (let y = bodyTop + lineGap; y < mapBottom - lineGap / 2; y += lineGap) {
out += `<rect x="${x.toFixed(2)}" y="${(y - lineGap * 0.28).toFixed(2)}" width="${(colW * (0.55 + 0.4 * ((y * 7) % 1))).toFixed(2)}" height="${(lineGap * 0.4).toFixed(2)}" fill="${LINE}"/>`
}
return out
}
let table = ''
const rows = 4
for (let r = 0; r <= rows; r++) {
const y = mapBottom + m * 0.5 + (tableH * r) / rows
table += `<line x1="${m}" y1="${y.toFixed(2)}" x2="${(w - m).toFixed(2)}" y2="${y.toFixed(2)}" stroke="${LINE}" stroke-width="${(lineGap * 0.12).toFixed(3)}"/>`
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${w} ${h}" preserveAspectRatio="none">`
+ `<rect width="${w}" height="${h}" fill="${PAPER}"/>`
+ `<rect x="${m}" y="${m}" width="${w - 2 * m}" height="${headH}" rx="${(headH * 0.12).toFixed(2)}" fill="${INK}"/>`
+ `<rect x="${(m + (w - 2 * m) * 0.25).toFixed(2)}" y="${(m + headH * 0.38).toFixed(2)}" width="${((w - 2 * m) * 0.5).toFixed(2)}" height="${(headH * 0.24).toFixed(2)}" fill="${PAPER}" opacity=".85"/>`
+ `<rect x="${mapX.toFixed(2)}" y="${bodyTop.toFixed(2)}" width="${mapW.toFixed(2)}" height="${(mapBottom - bodyTop).toFixed(2)}" fill="${SEA}"/>`
+ `<ellipse cx="${(mapX + mapW * 0.5).toFixed(2)}" cy="${((bodyTop + mapBottom) / 2).toFixed(2)}" rx="${(mapW * 0.36).toFixed(2)}" ry="${((mapBottom - bodyTop) * 0.34).toFixed(2)}" fill="${LAND}"/>`
+ nameLines(m) + nameLines(w - m - colW)
+ table
+ `<rect x="${m}" y="${(h - m - footH).toFixed(2)}" width="${w - 2 * m}" height="${footH}" rx="${(footH * 0.12).toFixed(2)}" fill="${ACCENT}"/>`
+ `<rect x="${(m + (w - 2 * m) * 0.15).toFixed(2)}" y="${(h - m - footH * 0.62).toFixed(2)}" width="${((w - 2 * m) * 0.7).toFixed(2)}" height="${(footH * 0.24).toFixed(2)}" fill="${INK}" opacity=".8"/>`
+ `</svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}

View file

@ -0,0 +1,77 @@
/**
* pageThumb.js: the FIRST PAGE of a printable HTML document (a card deck, a prayer packet) as an image
* a poster card can show.
*
* The poster rasteriser (library/packet/poster-raster.js) lifts an <svg> out of a poster; a deck or a
* packet is HTML pages instead, so it has nothing to lift. This takes the document the reader would
* print, keeps its first page, and wraps that page in an SVG <foreignObject>, which a browser draws
* through a plain <img>. Nothing is re-laid-out here: the page, its stylesheet and its size all come
* from the shared renderer.
*
* Two things an SVG shown through <img> does differently, both handled here:
* - it is an ISOLATED document that loads nothing, so a photo on the page is fetched now and inlined as
* a data: URL, but ONLY from this page's own origin: a fetch needs CORS, and the people-group photo host
* sends none, so a cross-origin photo is left out WITHOUT trying (trying only fills the console with
* refusals). Its frame stays, empty, and the printed document still carries the photo;
* - its markup must be XML, so the page is re-serialised with XMLSerializer (void elements closed,
* entities resolved) rather than pasted as HTML.
*
* A pilot in this plugin; if a second surface needs a document thumbnail it belongs in library/.
*/
const MAX_PHOTOS = 8
function withTimeout(promise, ms) {
return new Promise((resolve, reject) => {
const timer = setTimeout(() => reject(new Error('timed out')), ms)
promise.then((v) => { clearTimeout(timer); resolve(v) }, (e) => { clearTimeout(timer); reject(e) })
})
}
async function asDataUrl(src, timeoutMs) {
const res = await withTimeout(fetch(src, { mode: 'cors', cache: 'force-cache' }), timeoutMs)
if (!res.ok) throw new Error(`photo ${res.status}`)
const blob = await res.blob()
return new Promise((resolve, reject) => {
const r = new FileReader()
r.onload = () => resolve(r.result)
r.onerror = () => reject(r.error)
r.readAsDataURL(blob)
})
}
/**
* @param {string} html the whole printable document, exactly as the renderer builds it
* @param {object} o
* page CSS selector of one page (".card", ".sheet"); the first match is kept
* width the page's width in CSS px (the renderer's W)
* height the page's height in CSS px (the renderer's H)
* timeoutMs per photo
* @returns {Promise<string|null>} an image URL (image/svg+xml), or null when the document has no page
*/
export async function firstPageImage(html, { page, width, height, timeoutMs = 8000 } = {}) {
if (!html || !page || !width || !height) return null
const doc = new DOMParser().parseFromString(html, 'text/html')
const first = doc.body && doc.body.querySelector(page)
if (!first) return null
// Keep only the body child that holds the first page.
let top = first
while (top.parentElement && top.parentElement !== doc.body) top = top.parentElement
for (const n of [...doc.body.childNodes]) if (n !== top) n.remove()
const sameOrigin = (src) => { try { return new URL(src, document.baseURI).origin === location.origin } catch (_) { return false } }
const photos = [...top.querySelectorAll('img[src]')].filter((img) => !/^data:/i.test(img.getAttribute('src')))
let fetched = 0
for (const img of photos) {
const src = img.getAttribute('src')
if (fetched < MAX_PHOTOS && sameOrigin(src)) {
fetched++
try { img.setAttribute('src', await asDataUrl(src, timeoutMs)); continue } catch (_) { /* left out below */ }
}
img.removeAttribute('src')
}
const xhtml = new XMLSerializer().serializeToString(doc.documentElement)
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}">`
+ `<foreignObject x="0" y="0" width="${width}" height="${height}">${xhtml}</foreignObject></svg>`
return URL.createObjectURL(new Blob([svg], { type: 'image/svg+xml' }))
}

View file

@ -0,0 +1,69 @@
/**
* panelsSvg.js: a poster printed on several sheets, drawn as the sheets pulled apart, each join a red glue
* band, as one SVG image for a poster card's thumbnail.
*
* The geometry is the shared panel plan (library/components/poster/panelSheets.js planPanels + screenLayout):
* the same sheets, joins and content boxes the print uses. Inside each sheet's content box sit rows of cards,
* the way the Bless wall fills its 5 × 8 ft panels: a banner on the first sheet, a footer on the last, and
* card rows that never cross a join. The cards are a LAYOUT (blocks, no words): the Bless wall draws its real
* cards in its own plugin, which a library-only gallery cannot import.
*/
import { planPanels, screenLayout } from '@map/components/poster/panelSheets.js'
const PAPER = '#f7f4ec'
const INK = '#3b463d'
const ACCENT = '#6B4A78' // the Bless wall's purple
const PHOTO = '#e9e3d4'
const LINE = '#c9c3b3'
/**
* @param {{ sheetW, sheetH, count, direction, joinIn }} sheets a size's `sheets` from the shared ladder
* @param {{ borderIn?: number, gapIn?: number, cols?: number }} o
* @returns {string} an image URL (a data: URL of an SVG), drawn in inches
*/
export function panelsSvgUrl(sheets, { borderIn = sheets.joinIn || 2, gapIn = 0.75, cols = 14 } = {}) {
const plan = planPanels({ ...sheets, borderIn, joinFill: 'glue' })
const L = screenLayout(plan, { gapIn, pxPerIn: 1 })
const W = L.worldW, H = L.worldH
const s1 = Math.min(plan.sheetW, plan.sheetH)
const stroke = s1 / 200
let out = ''
for (const sh of plan.sheets) {
const at = L.sheets[sh.s]
const ox = at.x, oy = at.y
out += `<rect x="${ox}" y="${oy}" width="${plan.sheetW}" height="${plan.sheetH}" fill="${PAPER}" stroke="${LINE}" stroke-width="${stroke}"/>`
const c = sh.content
let top = oy + c.y0, bottom = oy + c.y1
const cw0 = c.x1 - c.x0
if (sh.first) { // the banner: a title band and the prayer-themes table
const bh = cw0 * 0.075, th = cw0 * 0.17
out += `<rect x="${ox + c.x0}" y="${top}" width="${cw0}" height="${bh}" fill="${INK}"/>`
out += `<rect x="${ox + c.x0}" y="${top + bh + cw0 * 0.01}" width="${cw0}" height="${th}" fill="none" stroke="${ACCENT}" stroke-width="${stroke * 2}"/>`
top += bh + th + cw0 * 0.025
}
if (sh.last) { // the footer: the blessing and the closing line
const fh = cw0 * 0.09
out += `<rect x="${ox + c.x0}" y="${bottom - fh}" width="${cw0}" height="${fh}" fill="${ACCENT}" opacity=".85"/>`
bottom -= fh + cw0 * 0.015
}
// rows of portrait cards (0.8 aspect), centred in the band, never crossing a join
const cw = cw0 / cols, ch = cw / 0.8
const rows = Math.max(0, Math.floor((bottom - top) / ch))
const y0 = top + Math.max(0, (bottom - top) - rows * ch) / 2
for (let r = 0; r < rows; r++) {
for (let k = 0; k < cols; k++) {
const x = ox + c.x0 + k * cw, y = y0 + r * ch
out += `<rect x="${x + cw * 0.08}" y="${y + ch * 0.06}" width="${cw * 0.84}" height="${cw * 0.84}" fill="${PHOTO}"/>`
out += `<rect x="${x + cw * 0.15}" y="${y + ch * 0.06 + cw * 0.9}" width="${cw * 0.7}" height="${ch * 0.06}" fill="${INK}" opacity=".7"/>`
}
}
if (sh.band) { // the glue band: red, dashed at its edge, as PanelSheets draws it
const b = sh.band
const ex = plan.direction === 'vertical' ? `x1="${ox}" y1="${oy + b.h}" x2="${ox + b.w}" y2="${oy + b.h}"` : `x1="${ox + b.w}" y1="${oy}" x2="${ox + b.w}" y2="${oy + b.h}"`
out += `<rect x="${ox + b.x}" y="${oy + b.y}" width="${b.w}" height="${b.h}" fill="rgba(176,0,0,.14)"/>`
out += `<line ${ex} stroke="#b00" stroke-width="${stroke * 1.5}" stroke-dasharray="${stroke * 6} ${stroke * 4}"/>`
}
}
const svg = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${W} ${H}" width="${(W * 4).toFixed(0)}" height="${(H * 4).toFixed(0)}">${out}</svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
}

View file

@ -7,8 +7,11 @@
* its name and is marked legacy.
*
* Each entry says where its renderer lives in library/ and, when the gallery can draw it, how:
* render(ctx) returns the template's HTML document drawn from the LIBRARY with the sample data, so a
* library change shows up in the gallery the next time it opens. Entries the gallery cannot draw yet
* render(ctx) draws the template from the LIBRARY with the sample data, so a library change shows up in
* the gallery the next time it opens. It returns either a poster's HTML (a map sheet: an <svg> the poster
* rasteriser lifts) or a printable DOCUMENT as { html, page, width, height } (a deck, a packet: HTML pages,
* whose first page becomes the thumbnail, src/pageThumb.js) or a ready image as { image } (panels drawn from
* the shared panel geometry, src/panelsSvg.js). Entries the gallery cannot draw yet
* say so (render: null) and the card shows the wall's "preview unavailable" placeholder; the details
* dialog states which part is missing, rather than a picture of something else.
*
@ -74,8 +77,8 @@ export function listConfigurations() {
renderer: 'library/packet/packet-render.js buildPacket (VARIANTS)',
settings: { variant },
sample: SAMPLE_COUNTRY_ID,
render: null,
missing: 'posterDesign.missing.packet',
// Page 1 of the packet (its map page) as the thumbnail; ctx.packetDocument builds it.
render: (ctx) => ctx.packetDocument(variant),
})),
...Object.entries(STOCK).map(([id, s]) => ({
name: `cards.${id}`,
@ -86,18 +89,19 @@ export function listConfigurations() {
renderer: 'library/packet/card-render.js createCardRenderer (STOCK)',
settings: { stock: id },
sample: SAMPLE_COUNTRY_ID,
render: null,
missing: 'posterDesign.missing.cards',
// The first card of the sample's deck; ctx.cardDeck builds the whole deck the wall would print.
render: (ctx) => ctx.cardDeck(id),
})),
{
name: 'wall.bless-panels',
family: 'wall',
labelKey: 'posterDesign.tpl.blessPanels',
size: '5 × 8 ft, 3 panels',
renderer: 'plugin-designs/wall-of-people-groups/src/blessPoster.js (not in library/ yet)',
settings: {},
renderer: 'library/components/poster/panelSheets.js planPanels (the sheets, joins and content boxes); the cards are drawn by plugin-designs/wall-of-people-groups/src/blessPoster.js',
settings: { size: '60x96', joinFill: 'glue' },
sample: null,
render: null,
// The panel geometry of the ladder's 5 × 8 ft size, with the card rows as a layout (src/panelsSvg.js).
render: (ctx) => ctx.panels('60x96'),
missing: 'posterDesign.missing.bless',
},
{

View file

@ -261,8 +261,22 @@ const FORMATS = {
// about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow.
// Output: <zone>.packet.html beside the posters (see FILE_SUFFIX), never over them.
packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 },
// ★ LARGE SHEETS WITH SIDE LANES (`sideLanes`): a whole country's hundreds of groups, every one named horizontally
// in two staggered columns per side, ONE leader per cluster of co-located groups, crossing-free by construction
// (see sideLanesLayout). Same 41.67 px/in as the 3x3, so the header, pins and footer keep their physical size.
// `nameMaxPt` caps the names' print size; the layout solves the largest size that fits the rows and columns.
'60x96': { w: 2500, h: 4000, gutter: 572, page: '60in 96in', pageWidthIn: 60, sideLanes: true, nameMaxPt: 20.7, photos: true },
'66x66': { w: 2750, h: 2750, gutter: 470, page: '66in 66in', pageWidthIn: 66, sideLanes: true, nameMaxPt: 18, photos: true },
'36x60': { w: 1500, h: 2500, gutter: 400, page: '36in 60in', pageWidthIn: 36, sideLanes: true, nameMaxPt: 14, photos: false },
};
const FMT_KEY = FORMATS[process.env.FORMAT] ? process.env.FORMAT : '3x4';
// The side-lane marks' styles: emitted ONLY on a sideLanes sheet, so every other format's page is byte-identical.
const SIDE_LANES_CSS = `
.slead{stroke:#5a5246;stroke-width:0.9;fill:none;opacity:.5}
.sbr{stroke:#5a5246;stroke-width:1.1;fill:none}
.srow{stroke:#b9ac8c;stroke-width:.3;fill:none;opacity:.7}
.spin{fill:#1c1c17;stroke:#f7f4ec;stroke-width:.8}
.snm{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-weight:700;fill:#1c1c17}`;
const FMT = FORMATS[FMT_KEY];
// Map-only sheets (packet page 1) skip the poster's header and footer furniture — see FORMATS.packet.
const MAP_ONLY = FMT.mapOnly === true;
@ -854,7 +868,9 @@ async function renderPoster(zone, pins, zoneFC) {
const floorDeg = CITIES ? _floor(Math.max(_w, _h)) : 1;
// PER-AXIS floor. A collinear cluster (zero span on one axis) must not be padded
// as if it were as wide as the other axis — that is what produced a 0.0% spread.
const pad = CITIES
// A side-lane sheet is the COUNTRY at its largest: a thin margin, since the names, not neighbours, fill the sides.
const pad = FMT.sideLanes ? [_w * 0.04, _h * 0.04]
: CITIES
? [_w * 0.22 + _floor(Math.max(_w, _h * 0.35)), _h * 0.22 + _floor(Math.max(_h, _w * 0.35))]
: [_w * 0.22 + floorDeg, _h * 0.22 + floorDeg];
const viewBbox = [bbox[0] - pad[0], bbox[1] - pad[1], bbox[2] + pad[0], bbox[3] + pad[1]];
@ -884,12 +900,14 @@ async function renderPoster(zone, pins, zoneFC) {
// frame is exactly what shows them.
// FULL SHEET HEIGHT — the map runs from just under the header to just above the
// footer rule, so there is no dead band anywhere on the page.
const FRAME_H = MAP_H;
// A side-lane sheet fits the frame to the country's own shape and centres it: its names run the sheet's full height
// beside it, so the frame need not, and a full-height frame would only shrink the country to show its neighbours.
const FRAME_H = FMT.sideLanes ? Math.min(MAP_H, FRAME_W / zoneAspect) : MAP_H;
// OPTICAL CENTRING, not geometric. A block centred by arithmetic looks like it
// is falling off the bottom of the sheet — the optical centre sits ABOVE the
// geometric one, which is why framers cut a bottom mat 1.25-1.4x the top. Here
// the remainder is split 42/58 so the bottom margin is the larger one.
const FRAME_Y = MAP_Y;
const FRAME_Y = FMT.sideLanes ? MAP_Y + (MAP_H - FRAME_H) / 2 : MAP_Y;
// PROJECT FROM viewBbox, NOT bbox.
// These two had drifted apart: `viewBbox` (bbox + 22% context pad) decides WHICH
// countries are drawn, while the projection scaled from the bare `bbox` — so the
@ -1247,7 +1265,8 @@ async function renderPoster(zone, pins, zoneFC) {
//
// This is what makes leaders viable again: leaders can only be routed without
// crossing if their endpoints are distinct, and the endpoints are these pins.
{
// A side-lane sheet keeps every pin at its TRUE position: co-located groups share one leader instead.
if (!FMT.sideLanes) {
// MIN_SEP MUST TRACK THE BADGE SIZE. It was a hardcoded 25 tuned against the
// old r=10.8 disc; leaving it fixed while the badge shrinks would keep pushing
// pins just as far apart and defeat the whole point of the smaller badge.
@ -1294,7 +1313,126 @@ async function renderPoster(zone, pins, zoneFC) {
// Feed the settled pixel positions back as lng/lat-free overrides.
const settled = prePins.map((p) => ({ ...p, _fixed: true }));
const laid = layoutLabels(settled, proj);
// ── SIDE LANES: a whole country's groups on one large sheet (FMT.sideLanes) ───────────────────────────
// Every name HORIZONTAL, on the left and right of the map, in two STAGGERED columns per side: the rows alternate
// inner/outer at half the line pitch, so each column keeps a full pitch and each row holds exactly ONE name.
// Co-located groups (within CL_R px) form a CLUSTER with ONE leader, a bracket and a block of names.
// CROSSING-FREE BY CONSTRUCTION, which the numbered design below could not reach at this density:
// · one vertical split (the weighted median x) sends each cluster to the nearer side, so runs never meet;
// · each leader is a short vertical stub to its own LANE, a horizontal run to the map edge, then a straight fan
// to its block; lanes are placed FARTHEST-FROM-THE-EDGE first, each clamped between its neighbouring lanes,
// so a stub never jumps a lane, and the fan keeps the lanes' top-to-bottom order (ordered points on two
// parallel lines, joined in order, cannot cross). The count is measured and returned, never assumed.
// A real photo (never the generic placeholder) sits on the OUTSIDE of its name when the format allows photos.
function sideLanesLayout(pinsIn) {
const X0 = MAP_X, X1 = MAP_X + MAP_W;
const BY0 = LABEL_TOP ?? FRAME_Y, BY1 = LABEL_BOT ?? (FRAME_Y + FRAME_H);
const PXI = PW / FMT.pageWidthIn;
const pins = pinsIn.map((p) => ({ ...p, px: p._px, py: p._py }));
// A photo only from the known people-group photo hosts, over https, and never the generic placeholder.
const PHOTO_HOSTS = /^https:\/\/(media\.joshuaproject\.net|joshuaproject\.net|images\.peoplegroups\.org|peoplegroupspublic\.blob\.core\.windows\.net)\//;
const photoOf = (p) => (FMT.photos && typeof p.image_url === 'string' && PHOTO_HOSTS.test(p.image_url) && !/no-photo-images/.test(p.image_url) ? p.image_url : null);
const CL_R = 14, clusters = [];
for (const p of pins) {
const c = clusters.find((q) => Math.hypot(q.x - p.px, q.y - p.py) <= CL_R);
if (c) { c.items.push(p); c.x += (p.px - c.x) / c.items.length; c.y += (p.py - c.y) / c.items.length; }
else clusters.push({ x: p.px, y: p.py, items: [p] });
}
for (const c of clusters) c.items.sort((a, b) => String(a.name).localeCompare(String(b.name), 'en'));
const byX = [...clusters].sort((a, b) => a.x - b.x);
let acc = 0, split = byX.length ? byX[0].x : 0;
for (const c of byX) { acc += c.items.length; if (acc >= pins.length / 2) { split = c.x; break; } }
for (const c of clusters) c.side = c.x <= split ? 'L' : 'R';
// THE TYPE: the largest size (capped by nameMaxPt) whose rows fit the band and whose two columns fit the gutter.
const FAN = 70, GAP = 10, PHOTO_GAP = 4;
const room = GUTTER - (SHEET_EDGE + 8) - FAN - GAP;
const photoPx = (f) => Math.floor(f * 1.18) - 1;
const longest = (f) => Math.max(...pins.map((p) => textW(String(p.name), f) * 1.06 + (photoOf(p) ? photoPx(f) + PHOTO_GAP : 0)));
const lineCount = (sd) => clusters.filter((c) => c.side === sd).reduce((a, c) => a + c.items.length, 0);
const maxLines = Math.max(1, lineCount('L'), lineCount('R'));
let font = Math.min((FMT.nameMaxPt || 14) / 72 * PXI, (BY1 - BY0) / (maxLines * 0.59 * 1.02));
while (font > 5 && 2 * (longest(font) + 8) > room) font -= 0.1;
const ROW = font * 1.18 / 2, PHOTO = photoPx(font), COLW = longest(font) + 8;
// LANES, farthest from the edge first (see above). PIN_CLEAR: within its safe gap a lane prefers a height
// that passes no other pin, at most 7.5 px from its own pin.
const LANE_GAP = 1.5, PIN_CLEAR = 4.5;
let laneJumps = 0;
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const mine = clusters.filter((c) => c.side === sd).sort((a, b) => (L ? b.x - a.x : a.x - b.x));
const lanes = [];
for (const c of mine) {
let lo = -Infinity, hi = Infinity;
for (const ly of lanes) { if (ly < c.y && ly > lo) lo = ly; if (ly >= c.y && ly < hi) hi = ly; }
let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);
if (lo + LANE_GAP > hi - LANE_GAP) { y = (lo + hi) / 2; laneJumps++; }
else {
const a0 = L ? X0 : c.x, a1 = L ? c.x : X1;
const blockers = clusters.filter((o) => o !== c && o.x > a0 + 1 && o.x < a1 - 1);
let best = y, bestHits = Infinity;
for (let t = Math.max(lo + LANE_GAP, c.y - 7.5); t <= Math.min(hi - LANE_GAP, c.y + 7.5); t += 0.75) {
const hits = blockers.filter((o) => Math.abs(o.y - t) < PIN_CLEAR).length;
if (hits < bestHits || (hits === bestHits && Math.abs(t - c.y) < Math.abs(best - c.y))) { best = t; bestHits = hits; }
}
if (bestHits !== Infinity) y = best;
}
c.lane = y; lanes.push(y);
}
}
// BLOCKS, stacked top to bottom in lane order, each as near its lane as the rows allow.
const rowsAvail = Math.floor((BY1 - BY0) / ROW);
let leadersS = '', labelsS = '', dotsS = '', overflow = 0;
const placed = [];
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const blocks = clusters.filter((c) => c.side === sd).sort((a, b) => a.lane - b.lane)
.map((c) => ({ c, n: c.items.length, want: (c.lane - BY0) / ROW }));
const pos = []; let cur = 0;
for (const b of blocks) { const q = Math.max(cur, Math.round(b.want - b.n / 2)); pos.push(q); cur = q + b.n; }
let over = cur - rowsAvail;
for (let k = pos.length - 1; k >= 0 && over > 0; k--) {
const lim = k ? pos[k - 1] + blocks[k - 1].n : 0, can = Math.min(over, pos[k] - lim);
for (let j = k; j < pos.length; j++) pos[j] -= can;
over -= can;
}
overflow += Math.max(0, over);
const edgeX = L ? X0 : X1, portX = L ? X0 - FAN : X1 + FAN;
const innerX = L ? portX - 6 : portX + 6, outerX = L ? innerX - COLW : innerX + COLW;
blocks.forEach((b, k) => {
const c = b.c, r0 = pos[k], midY = BY0 + (r0 + b.n / 2) * ROW;
leadersS += `<path class="slead" d="M${c.x.toFixed(1)} ${c.y.toFixed(1)}L${c.x.toFixed(1)} ${c.lane.toFixed(2)}L${edgeX.toFixed(1)} ${c.lane.toFixed(2)}L${portX.toFixed(1)} ${midY.toFixed(1)}"/>`;
if (b.n > 1) leadersS += `<path class="sbr" d="M${portX.toFixed(1)} ${(BY0 + r0 * ROW + 1).toFixed(1)}V${(BY0 + (r0 + b.n) * ROW - 1).toFixed(1)}"/>`;
dotsS += `<circle class="spin" cx="${c.x.toFixed(1)}" cy="${c.y.toFixed(1)}" r="${b.n > 1 ? 4.4 : 3.2}"/>`;
c.items.forEach((g, j) => {
const r = r0 + j, rowY = BY0 + (r + 0.5) * ROW, x = r % 2 === 0 ? innerX : outerX;
if (r % 2 === 1) leadersS += `<path class="srow" d="M${innerX.toFixed(1)} ${rowY.toFixed(1)}H${(L ? outerX + 3 : outerX - 3).toFixed(1)}"/>`;
labelsS += `<text class="snm" x="${x.toFixed(1)}" y="${(rowY + font * 0.34).toFixed(1)}" font-size="${font.toFixed(2)}"${L ? ' text-anchor="end"' : ''}>${esc(String(g.name))}</text>`;
const ph = photoOf(g);
if (ph) {
const tw = textW(String(g.name), font) * 1.06;
const px = L ? x - tw - PHOTO_GAP - PHOTO : x + tw + PHOTO_GAP;
labelsS += `<image href="${esc(ph)}" x="${px.toFixed(1)}" y="${(rowY - PHOTO / 2).toFixed(1)}" width="${PHOTO}" height="${PHOTO}" preserveAspectRatio="xMidYMid slice"/>`;
}
placed.push({ g, side: sd, r, photo: !!ph });
});
});
}
// MEASURED, not assumed: leader crossings (every segment pair of different leaders).
const polys = [...leadersS.matchAll(/class="slead" d="M([^"]+)"/g)].map((m) => m[1].split('L').map((q) => q.trim().split(' ').map(Number)));
const segsOf = polys.map((pl) => pl.slice(1).map((q, k) => [pl[k], q]));
const sx = (p, q, r) => (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0]);
const segX = (a, b) => (sx(a[0], a[1], b[0]) > 0) !== (sx(a[0], a[1], b[1]) > 0) && (sx(b[0], b[1], a[0]) > 0) !== (sx(b[0], b[1], a[1]) > 0);
let crossingsS = 0;
for (let i = 0; i < segsOf.length; i++) for (let j = i + 1; j < segsOf.length; j++) if (segsOf[i].some((a) => segsOf[j].some((b) => segX(a, b)))) crossingsS++;
const stats = { names: placed.length, pins: pins.length, clusters: clusters.length, crossings: crossingsS, overflowRows: overflow,
laneJumps, photos: placed.filter((p) => p.photo).length, namePt: +(font / PXI * 72).toFixed(1), photoMm: FMT.photos ? +(PHOTO / PXI * 25.4).toFixed(1) : 0 };
return { leaders: leadersS, labels: labelsS, pinDots: dotsS, crossings: crossingsS, pins, stats };
}
const laid = FMT.sideLanes ? [] : layoutLabels(settled, proj);
const sides = FMT.sideLanes ? sideLanesLayout(settled) : null;
// What the city layer must keep clear of: the drawn pins (laid out, or true positions on a side-lane sheet).
const avoidPins = sides ? sides.pins : laid;
// ── CAPITALS + MAJOR CITIES ────────────────────────────────────────────────
// Orientation marks, deliberately SUBORDINATE to the people-group pins. Imhof's
@ -1367,7 +1505,7 @@ async function renderPoster(zone, pins, zoneFC) {
// projected point is not where the badge is drawn. Measuring against the
// source coordinate let Saravan's label land 1.4px from a badge while the
// guard believed it was clear. This block therefore runs AFTER layoutLabels.
for (const pin of laid) {
for (const pin of avoidPins) {
const px = pin.px, py = pin.py;
const dx = px < x0 ? x0 - px : (px > x1 ? px - x1 : 0);
const dy = py < yTop ? yTop - py : (py > yBot ? py - yBot : 0);
@ -1697,8 +1835,9 @@ async function renderPoster(zone, pins, zoneFC) {
const tx = it.laneX + it.dirOut * 5;
labels += `<text x="${tx.toFixed(1)}" y="${(it.labelY + it.font * 0.34).toFixed(1)}" text-anchor="${it.tanchor}" font-size="${it.font.toFixed(1)}" class="lbl">${esc(it.name)}</text>`;
});
if (sides) { leaders += sides.leaders; pinDots += sides.pinDots; labels += sides.labels; }
if (!octiOK) console.warn(` ⚠ non-octilinear segment in ${zone.zoneKey}`);
const crossings = countPolyCrossings(routes);
const crossings = sides ? sides.crossings : countPolyCrossings(routes);
const diagFrac = bestD;
// ── TITLE vs SUBTITLE ──────────────────────────────────────────────────────
@ -2678,8 +2817,8 @@ async function renderPoster(zone, pins, zoneFC) {
page. Collapse the flow height to the scaled height. */
html,body{width:${(FMT.pageWidthIn * 96).toFixed(0)}px;height:${(FMT.pageWidthIn * 96 * FMT.h / FMT.w).toFixed(0)}px;overflow:hidden}
}
</style></head><body><div class="stage">${svg}</div></body></html>`;
return { html, title, octiOK, crossings, diagFrac };
${FMT.sideLanes ? SIDE_LANES_CSS + '\n' : ''}</style></head><body><div class="stage">${svg}</div></body></html>`;
return sides ? { html, title, octiOK, crossings, diagFrac, sideLanes: sides.stats } : { html, title, octiOK, crossings, diagFrac };
}
return { renderPoster, CITIES, COUNTRIES, FILE_SUFFIX, FMT_KEY, FMT, MAP_ONLY, FORMATS };

View file

@ -259,8 +259,22 @@ const FORMATS = {
// about 1,600px of height against the letter sheet's ~1,200, which is what lets the names grow.
// Output: <zone>.packet.html beside the posters (see FILE_SUFFIX), never over them.
packet: { w: 1500, h: 1941, gutter: 330, page: 'letter portrait', pageWidthIn: 8.5, mapOnly: true, header: 250, footer: 90 },
// ★ LARGE SHEETS WITH SIDE LANES (`sideLanes`): a whole country's hundreds of groups, every one named horizontally
// in two staggered columns per side, ONE leader per cluster of co-located groups, crossing-free by construction
// (see sideLanesLayout). Same 41.67 px/in as the 3x3, so the header, pins and footer keep their physical size.
// `nameMaxPt` caps the names' print size; the layout solves the largest size that fits the rows and columns.
'60x96': { w: 2500, h: 4000, gutter: 572, page: '60in 96in', pageWidthIn: 60, sideLanes: true, nameMaxPt: 20.7, photos: true },
'66x66': { w: 2750, h: 2750, gutter: 470, page: '66in 66in', pageWidthIn: 66, sideLanes: true, nameMaxPt: 18, photos: true },
'36x60': { w: 1500, h: 2500, gutter: 400, page: '36in 60in', pageWidthIn: 36, sideLanes: true, nameMaxPt: 14, photos: false },
};
const FMT_KEY = FORMATS[process.env.FORMAT] ? process.env.FORMAT : '3x4';
// The side-lane marks' styles: emitted ONLY on a sideLanes sheet, so every other format's page is byte-identical.
const SIDE_LANES_CSS = `
.slead{stroke:#5a5246;stroke-width:0.9;fill:none;opacity:.5}
.sbr{stroke:#5a5246;stroke-width:1.1;fill:none}
.srow{stroke:#b9ac8c;stroke-width:.3;fill:none;opacity:.7}
.spin{fill:#1c1c17;stroke:#f7f4ec;stroke-width:.8}
.snm{font-family:Lato,'Segoe UI',system-ui,sans-serif;font-weight:700;fill:#1c1c17}`;
const FMT = FORMATS[FMT_KEY];
// Map-only sheets (packet page 1) skip the poster's header and footer furniture — see FORMATS.packet.
const MAP_ONLY = FMT.mapOnly === true;
@ -852,7 +866,9 @@ async function renderPoster(zone, pins, zoneFC) {
const floorDeg = CITIES ? _floor(Math.max(_w, _h)) : 1;
// PER-AXIS floor. A collinear cluster (zero span on one axis) must not be padded
// as if it were as wide as the other axis — that is what produced a 0.0% spread.
const pad = CITIES
// A side-lane sheet is the COUNTRY at its largest: a thin margin, since the names, not neighbours, fill the sides.
const pad = FMT.sideLanes ? [_w * 0.04, _h * 0.04]
: CITIES
? [_w * 0.22 + _floor(Math.max(_w, _h * 0.35)), _h * 0.22 + _floor(Math.max(_h, _w * 0.35))]
: [_w * 0.22 + floorDeg, _h * 0.22 + floorDeg];
const viewBbox = [bbox[0] - pad[0], bbox[1] - pad[1], bbox[2] + pad[0], bbox[3] + pad[1]];
@ -882,12 +898,14 @@ async function renderPoster(zone, pins, zoneFC) {
// frame is exactly what shows them.
// FULL SHEET HEIGHT — the map runs from just under the header to just above the
// footer rule, so there is no dead band anywhere on the page.
const FRAME_H = MAP_H;
// A side-lane sheet fits the frame to the country's own shape and centres it: its names run the sheet's full height
// beside it, so the frame need not, and a full-height frame would only shrink the country to show its neighbours.
const FRAME_H = FMT.sideLanes ? Math.min(MAP_H, FRAME_W / zoneAspect) : MAP_H;
// OPTICAL CENTRING, not geometric. A block centred by arithmetic looks like it
// is falling off the bottom of the sheet — the optical centre sits ABOVE the
// geometric one, which is why framers cut a bottom mat 1.25-1.4x the top. Here
// the remainder is split 42/58 so the bottom margin is the larger one.
const FRAME_Y = MAP_Y;
const FRAME_Y = FMT.sideLanes ? MAP_Y + (MAP_H - FRAME_H) / 2 : MAP_Y;
// PROJECT FROM viewBbox, NOT bbox.
// These two had drifted apart: `viewBbox` (bbox + 22% context pad) decides WHICH
// countries are drawn, while the projection scaled from the bare `bbox` — so the
@ -1245,7 +1263,8 @@ async function renderPoster(zone, pins, zoneFC) {
//
// This is what makes leaders viable again: leaders can only be routed without
// crossing if their endpoints are distinct, and the endpoints are these pins.
{
// A side-lane sheet keeps every pin at its TRUE position: co-located groups share one leader instead.
if (!FMT.sideLanes) {
// MIN_SEP MUST TRACK THE BADGE SIZE. It was a hardcoded 25 tuned against the
// old r=10.8 disc; leaving it fixed while the badge shrinks would keep pushing
// pins just as far apart and defeat the whole point of the smaller badge.
@ -1292,7 +1311,126 @@ async function renderPoster(zone, pins, zoneFC) {
// Feed the settled pixel positions back as lng/lat-free overrides.
const settled = prePins.map((p) => ({ ...p, _fixed: true }));
const laid = layoutLabels(settled, proj);
// ── SIDE LANES: a whole country's groups on one large sheet (FMT.sideLanes) ───────────────────────────
// Every name HORIZONTAL, on the left and right of the map, in two STAGGERED columns per side: the rows alternate
// inner/outer at half the line pitch, so each column keeps a full pitch and each row holds exactly ONE name.
// Co-located groups (within CL_R px) form a CLUSTER with ONE leader, a bracket and a block of names.
// CROSSING-FREE BY CONSTRUCTION, which the numbered design below could not reach at this density:
// · one vertical split (the weighted median x) sends each cluster to the nearer side, so runs never meet;
// · each leader is a short vertical stub to its own LANE, a horizontal run to the map edge, then a straight fan
// to its block; lanes are placed FARTHEST-FROM-THE-EDGE first, each clamped between its neighbouring lanes,
// so a stub never jumps a lane, and the fan keeps the lanes' top-to-bottom order (ordered points on two
// parallel lines, joined in order, cannot cross). The count is measured and returned, never assumed.
// A real photo (never the generic placeholder) sits on the OUTSIDE of its name when the format allows photos.
function sideLanesLayout(pinsIn) {
const X0 = MAP_X, X1 = MAP_X + MAP_W;
const BY0 = LABEL_TOP ?? FRAME_Y, BY1 = LABEL_BOT ?? (FRAME_Y + FRAME_H);
const PXI = PW / FMT.pageWidthIn;
const pins = pinsIn.map((p) => ({ ...p, px: p._px, py: p._py }));
// A photo only from the known people-group photo hosts, over https, and never the generic placeholder.
const PHOTO_HOSTS = /^https:\/\/(media\.joshuaproject\.net|joshuaproject\.net|images\.peoplegroups\.org|peoplegroupspublic\.blob\.core\.windows\.net)\//;
const photoOf = (p) => (FMT.photos && typeof p.image_url === 'string' && PHOTO_HOSTS.test(p.image_url) && !/no-photo-images/.test(p.image_url) ? p.image_url : null);
const CL_R = 14, clusters = [];
for (const p of pins) {
const c = clusters.find((q) => Math.hypot(q.x - p.px, q.y - p.py) <= CL_R);
if (c) { c.items.push(p); c.x += (p.px - c.x) / c.items.length; c.y += (p.py - c.y) / c.items.length; }
else clusters.push({ x: p.px, y: p.py, items: [p] });
}
for (const c of clusters) c.items.sort((a, b) => String(a.name).localeCompare(String(b.name), 'en'));
const byX = [...clusters].sort((a, b) => a.x - b.x);
let acc = 0, split = byX.length ? byX[0].x : 0;
for (const c of byX) { acc += c.items.length; if (acc >= pins.length / 2) { split = c.x; break; } }
for (const c of clusters) c.side = c.x <= split ? 'L' : 'R';
// THE TYPE: the largest size (capped by nameMaxPt) whose rows fit the band and whose two columns fit the gutter.
const FAN = 70, GAP = 10, PHOTO_GAP = 4;
const room = GUTTER - (SHEET_EDGE + 8) - FAN - GAP;
const photoPx = (f) => Math.floor(f * 1.18) - 1;
const longest = (f) => Math.max(...pins.map((p) => textW(String(p.name), f) * 1.06 + (photoOf(p) ? photoPx(f) + PHOTO_GAP : 0)));
const lineCount = (sd) => clusters.filter((c) => c.side === sd).reduce((a, c) => a + c.items.length, 0);
const maxLines = Math.max(1, lineCount('L'), lineCount('R'));
let font = Math.min((FMT.nameMaxPt || 14) / 72 * PXI, (BY1 - BY0) / (maxLines * 0.59 * 1.02));
while (font > 5 && 2 * (longest(font) + 8) > room) font -= 0.1;
const ROW = font * 1.18 / 2, PHOTO = photoPx(font), COLW = longest(font) + 8;
// LANES, farthest from the edge first (see above). PIN_CLEAR: within its safe gap a lane prefers a height
// that passes no other pin, at most 7.5 px from its own pin.
const LANE_GAP = 1.5, PIN_CLEAR = 4.5;
let laneJumps = 0;
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const mine = clusters.filter((c) => c.side === sd).sort((a, b) => (L ? b.x - a.x : a.x - b.x));
const lanes = [];
for (const c of mine) {
let lo = -Infinity, hi = Infinity;
for (const ly of lanes) { if (ly < c.y && ly > lo) lo = ly; if (ly >= c.y && ly < hi) hi = ly; }
let y = Math.min(Math.max(c.y, lo + LANE_GAP), hi - LANE_GAP);
if (lo + LANE_GAP > hi - LANE_GAP) { y = (lo + hi) / 2; laneJumps++; }
else {
const a0 = L ? X0 : c.x, a1 = L ? c.x : X1;
const blockers = clusters.filter((o) => o !== c && o.x > a0 + 1 && o.x < a1 - 1);
let best = y, bestHits = Infinity;
for (let t = Math.max(lo + LANE_GAP, c.y - 7.5); t <= Math.min(hi - LANE_GAP, c.y + 7.5); t += 0.75) {
const hits = blockers.filter((o) => Math.abs(o.y - t) < PIN_CLEAR).length;
if (hits < bestHits || (hits === bestHits && Math.abs(t - c.y) < Math.abs(best - c.y))) { best = t; bestHits = hits; }
}
if (bestHits !== Infinity) y = best;
}
c.lane = y; lanes.push(y);
}
}
// BLOCKS, stacked top to bottom in lane order, each as near its lane as the rows allow.
const rowsAvail = Math.floor((BY1 - BY0) / ROW);
let leadersS = '', labelsS = '', dotsS = '', overflow = 0;
const placed = [];
for (const sd of ['L', 'R']) {
const L = sd === 'L';
const blocks = clusters.filter((c) => c.side === sd).sort((a, b) => a.lane - b.lane)
.map((c) => ({ c, n: c.items.length, want: (c.lane - BY0) / ROW }));
const pos = []; let cur = 0;
for (const b of blocks) { const q = Math.max(cur, Math.round(b.want - b.n / 2)); pos.push(q); cur = q + b.n; }
let over = cur - rowsAvail;
for (let k = pos.length - 1; k >= 0 && over > 0; k--) {
const lim = k ? pos[k - 1] + blocks[k - 1].n : 0, can = Math.min(over, pos[k] - lim);
for (let j = k; j < pos.length; j++) pos[j] -= can;
over -= can;
}
overflow += Math.max(0, over);
const edgeX = L ? X0 : X1, portX = L ? X0 - FAN : X1 + FAN;
const innerX = L ? portX - 6 : portX + 6, outerX = L ? innerX - COLW : innerX + COLW;
blocks.forEach((b, k) => {
const c = b.c, r0 = pos[k], midY = BY0 + (r0 + b.n / 2) * ROW;
leadersS += `<path class="slead" d="M${c.x.toFixed(1)} ${c.y.toFixed(1)}L${c.x.toFixed(1)} ${c.lane.toFixed(2)}L${edgeX.toFixed(1)} ${c.lane.toFixed(2)}L${portX.toFixed(1)} ${midY.toFixed(1)}"/>`;
if (b.n > 1) leadersS += `<path class="sbr" d="M${portX.toFixed(1)} ${(BY0 + r0 * ROW + 1).toFixed(1)}V${(BY0 + (r0 + b.n) * ROW - 1).toFixed(1)}"/>`;
dotsS += `<circle class="spin" cx="${c.x.toFixed(1)}" cy="${c.y.toFixed(1)}" r="${b.n > 1 ? 4.4 : 3.2}"/>`;
c.items.forEach((g, j) => {
const r = r0 + j, rowY = BY0 + (r + 0.5) * ROW, x = r % 2 === 0 ? innerX : outerX;
if (r % 2 === 1) leadersS += `<path class="srow" d="M${innerX.toFixed(1)} ${rowY.toFixed(1)}H${(L ? outerX + 3 : outerX - 3).toFixed(1)}"/>`;
labelsS += `<text class="snm" x="${x.toFixed(1)}" y="${(rowY + font * 0.34).toFixed(1)}" font-size="${font.toFixed(2)}"${L ? ' text-anchor="end"' : ''}>${esc(String(g.name))}</text>`;
const ph = photoOf(g);
if (ph) {
const tw = textW(String(g.name), font) * 1.06;
const px = L ? x - tw - PHOTO_GAP - PHOTO : x + tw + PHOTO_GAP;
labelsS += `<image href="${esc(ph)}" x="${px.toFixed(1)}" y="${(rowY - PHOTO / 2).toFixed(1)}" width="${PHOTO}" height="${PHOTO}" preserveAspectRatio="xMidYMid slice"/>`;
}
placed.push({ g, side: sd, r, photo: !!ph });
});
});
}
// MEASURED, not assumed: leader crossings (every segment pair of different leaders).
const polys = [...leadersS.matchAll(/class="slead" d="M([^"]+)"/g)].map((m) => m[1].split('L').map((q) => q.trim().split(' ').map(Number)));
const segsOf = polys.map((pl) => pl.slice(1).map((q, k) => [pl[k], q]));
const sx = (p, q, r) => (r[1] - p[1]) * (q[0] - p[0]) - (q[1] - p[1]) * (r[0] - p[0]);
const segX = (a, b) => (sx(a[0], a[1], b[0]) > 0) !== (sx(a[0], a[1], b[1]) > 0) && (sx(b[0], b[1], a[0]) > 0) !== (sx(b[0], b[1], a[1]) > 0);
let crossingsS = 0;
for (let i = 0; i < segsOf.length; i++) for (let j = i + 1; j < segsOf.length; j++) if (segsOf[i].some((a) => segsOf[j].some((b) => segX(a, b)))) crossingsS++;
const stats = { names: placed.length, pins: pins.length, clusters: clusters.length, crossings: crossingsS, overflowRows: overflow,
laneJumps, photos: placed.filter((p) => p.photo).length, namePt: +(font / PXI * 72).toFixed(1), photoMm: FMT.photos ? +(PHOTO / PXI * 25.4).toFixed(1) : 0 };
return { leaders: leadersS, labels: labelsS, pinDots: dotsS, crossings: crossingsS, pins, stats };
}
const laid = FMT.sideLanes ? [] : layoutLabels(settled, proj);
const sides = FMT.sideLanes ? sideLanesLayout(settled) : null;
// What the city layer must keep clear of: the drawn pins (laid out, or true positions on a side-lane sheet).
const avoidPins = sides ? sides.pins : laid;
// ── CAPITALS + MAJOR CITIES ────────────────────────────────────────────────
// Orientation marks, deliberately SUBORDINATE to the people-group pins. Imhof's
@ -1365,7 +1503,7 @@ async function renderPoster(zone, pins, zoneFC) {
// projected point is not where the badge is drawn. Measuring against the
// source coordinate let Saravan's label land 1.4px from a badge while the
// guard believed it was clear. This block therefore runs AFTER layoutLabels.
for (const pin of laid) {
for (const pin of avoidPins) {
const px = pin.px, py = pin.py;
const dx = px < x0 ? x0 - px : (px > x1 ? px - x1 : 0);
const dy = py < yTop ? yTop - py : (py > yBot ? py - yBot : 0);
@ -1695,8 +1833,9 @@ async function renderPoster(zone, pins, zoneFC) {
const tx = it.laneX + it.dirOut * 5;
labels += `<text x="${tx.toFixed(1)}" y="${(it.labelY + it.font * 0.34).toFixed(1)}" text-anchor="${it.tanchor}" font-size="${it.font.toFixed(1)}" class="lbl">${esc(it.name)}</text>`;
});
if (sides) { leaders += sides.leaders; pinDots += sides.pinDots; labels += sides.labels; }
if (!octiOK) console.warn(` ⚠ non-octilinear segment in ${zone.zoneKey}`);
const crossings = countPolyCrossings(routes);
const crossings = sides ? sides.crossings : countPolyCrossings(routes);
const diagFrac = bestD;
// ── TITLE vs SUBTITLE ──────────────────────────────────────────────────────
@ -2676,8 +2815,8 @@ async function renderPoster(zone, pins, zoneFC) {
page. Collapse the flow height to the scaled height. */
html,body{width:${(FMT.pageWidthIn * 96).toFixed(0)}px;height:${(FMT.pageWidthIn * 96 * FMT.h / FMT.w).toFixed(0)}px;overflow:hidden}
}
</style></head><body><div class="stage">${svg}</div></body></html>`;
return { html, title, octiOK, crossings, diagFrac };
${FMT.sideLanes ? SIDE_LANES_CSS + '\n' : ''}</style></head><body><div class="stage">${svg}</div></body></html>`;
return sides ? { html, title, octiOK, crossings, diagFrac, sideLanes: sides.stats } : { html, title, octiOK, crossings, diagFrac };
}
return { renderPoster, CITIES, COUNTRIES, FILE_SUFFIX, FMT_KEY, FMT, MAP_ONLY, FORMATS };

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

View file

@ -1070,7 +1070,7 @@
"bundle": "poster-design",
"scriptUrl": "./poster-design/poster-design.js",
"output": "dx-maps/poster-design/poster-design.js",
"integrity": "sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy",
"integrity": "sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD",
"elements": [
"poster-design"
],
@ -1093,7 +1093,7 @@
"scriptUrl": "./poster-design/poster-design.js",
"embed": {
"iframe": "<iframe src=\"./poster-design/poster-design-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-design-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-design-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
}
},
{
@ -1101,7 +1101,7 @@
"stage": "experimental",
"label": "Poster Design — Sizes",
"file": "poster-sizes.vue",
"description": "Every poster size the print picker offers (standard paper up to 5 × 8 ft), each drawn as a",
"description": "Every poster size the print picker offers, from standard paper to 5 × 8 ft, drawn to scale.",
"element": "poster-design",
"type": "single",
"isMap": false,
@ -1109,7 +1109,7 @@
"scriptUrl": "./poster-design/poster-design.js",
"embed": {
"iframe": "<iframe src=\"./poster-design/poster-sizes/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-sizes\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-sizes\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
}
},
{
@ -1125,7 +1125,7 @@
"scriptUrl": "./poster-design/poster-design.js",
"embed": {
"iframe": "<iframe src=\"./poster-design/poster-styles/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-styles\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-styles\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
}
}
]

View file

@ -1070,7 +1070,7 @@
"bundle": "poster-design",
"scriptUrl": "./poster-design/poster-design.js",
"output": "dx-maps/poster-design/poster-design.js",
"integrity": "sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy",
"integrity": "sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD",
"elements": [
"poster-design"
],
@ -1093,7 +1093,7 @@
"scriptUrl": "./poster-design/poster-design.js",
"embed": {
"iframe": "<iframe src=\"./poster-design/poster-design-mfe/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-design-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-design-mfe\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
}
},
{
@ -1101,7 +1101,7 @@
"stage": "experimental",
"label": "Poster Design — Sizes",
"file": "poster-sizes.vue",
"description": "Every poster size the print picker offers (standard paper up to 5 × 8 ft), each drawn as a",
"description": "Every poster size the print picker offers, from standard paper to 5 × 8 ft, drawn to scale.",
"element": "poster-design",
"type": "single",
"isMap": false,
@ -1109,7 +1109,7 @@
"scriptUrl": "./poster-design/poster-design.js",
"embed": {
"iframe": "<iframe src=\"./poster-design/poster-sizes/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-sizes\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-sizes\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
}
},
{
@ -1125,7 +1125,7 @@
"scriptUrl": "./poster-design/poster-design.js",
"embed": {
"iframe": "<iframe src=\"./poster-design/poster-styles/index.html?utm_source=YOUR_SOURCE\" width=\"100%\" height=\"600\" style=\"border:none;border-radius:12px;\" allowfullscreen></iframe>",
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-styles\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
"script": "<script>window.MAP_APP_API_URL=\"https://pray.doxa.life\";</script>\n<script src=\"./poster-design/poster-design.js\" integrity=\"sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD\" crossorigin=\"anonymous\"></script>\n<!-- provider: \"maplibre\" is the default; change it to \"mapbox\" and fill tk to use Mapbox. -->\n<poster-design profile-config='{\"profile\":\"poster-styles\",\"provider\":\"maplibre\",\"tk\":\"YOUR_MAPBOX_TOKEN\",\"utmSource\":\"YOUR_SOURCE\",\"assetsBase\":\"YOUR_TREE_URL/assets\"}'></poster-design>"
}
}
]

View file

@ -72,7 +72,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <poster-design> custom element. -->
<script src="../poster-design.js" integrity="sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy" crossorigin="anonymous"></script>
<script src="../poster-design.js" integrity="sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

File diff suppressed because one or more lines are too long

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <poster-design> custom element. -->
<script src="../poster-design.js" integrity="sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy" crossorigin="anonymous"></script>
<script src="../poster-design.js" integrity="sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order:

View file

@ -67,7 +67,7 @@
<script>window.MAP_APP_API_URL = "https://pray.doxa.life";</script>
<!-- Bundle IIFE — registers the <poster-design> custom element. -->
<script src="../poster-design.js" integrity="sha384-kxKGb9vfzkFOaVMv8rx+eOITZVBLLlZzEFAJGmdK2RqRmG26cjpsPJwk5fNkFIsy" crossorigin="anonymous"></script>
<script src="../poster-design.js" integrity="sha384-hw+amWf3FSzpTkOfGjFV8CvZgRPBPMY8q/r5qPK5r2Fc1W4P8Lkh5ZnSmpfVTLTD" crossorigin="anonymous"></script>
<script>
// Mapbox token resolution — the A3 ladder, in precedence order: