Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ A file named `guide.md` produces `guide.html`. A folder named `docs` produces `d

Relative links and images remain unchanged, so they resolve from the generated HTML file. The site needs internet access to load its pinned Markdown-it dependency.

`geomap` code fences render GeoJSON layers as interactive maps with searchable feature tables and Google Maps links for points. Maps load Leaflet and OpenStreetMap tiles online; the table remains usable if the map fails to load. See the [GeoMap guide](https://github.com/asafmor/pageleaf/blob/main/docs/geomap.md) and [example](https://github.com/asafmor/pageleaf/blob/main/examples/geomap.md).

For uploads, install [Git](https://git-scm.com/downloads) and [GitHub CLI](https://cli.github.com/), then run `gh auth login --hostname github.com`. Use `--upload my-docs` for the signed-in user's personal account, or `--upload my-org/my-docs` for an explicit owner. The repository must already exist and permit direct pushes; an empty repository is supported.

Uploads keep the generated filename (`docs.html` in the example), replace that remote file automatically, and leave other files unchanged. Identical content creates no commit. Commits use the signed-in account's GitHub noreply identity. `--force` controls local output replacement only. If an upload fails, the local HTML remains available; fix the reported problem and rerun with `--force`. Uploading stores the file in GitHub; it does not configure GitHub Pages.
Expand Down
33 changes: 33 additions & 0 deletions docs/geomap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# GeoMap fences

Put a JSON object with a `layers` array inside a `geomap` Markdown code fence. Generate the page as usual:

```bash
npx pageleaf examples/geomap.md --open
```

See the [complete example](../examples/geomap.md) for points, routes, a boundary, and multiple maps.

Each layer requires nonempty `id`, `name`, and `role` strings and a `geojson` object. IDs must be unique within a map. GeoJSON may be a FeatureCollection, Feature, or raw geometry; all standard geometry types are supported. Optional layer `source` metadata accepts string fields `url`, `publisher`, `retrievedAt`, and `originalFormat`. GPX conversion must happen before rendering; source files are never downloaded or converted by Pageleaf.

The map uses [Leaflet 1.9.4](https://leafletjs.com/download.html) and OpenStreetMap tiles. It needs internet access for those assets. Pageleaf embeds its own map component in the HTML, so the generated file needs no adjacent scripts or backend. If Leaflet cannot load, the feature table and its links remain available. The document itself still needs the existing Markdown-it CDN dependency.

All layers start visible and can be toggled under **Layers**. Routes use different colors and line patterns. Point categories select a marker symbol where recognized; unknown categories use a generic marker. The initial view includes every visible geometry, with a maximum initial zoom of 15 for isolated points.

The table contains one row per Feature, including a named fallback for raw geometries. It opens by default for up to eight rows; larger tables start collapsed and offer a text filter. Optional columns appear when the data supplies them:

| Column | Property |
| --- | --- |
| Name | `name`, or layer name and feature number |
| Category / Type | `category`, then `type`, then layer role |
| Layer | Layer name; hidden layers retain their rows with a visible state label |
| Route section | `routeSection` or `route_section` |
| Details | `note`, `description`, or `desc` |
| 📍 | Google Maps link for Point coordinates only; other geometry cells stay empty |
| Source | Feature `source.url`, `source` URL string, `sourceUrl`, or `source_url`; falls back to layer `source.url` |

Select a name with the keyboard, or click a row, to reveal its layer, scroll to the map, focus the geometry, and open its popup. Selecting a map feature highlights it and its row without scrolling the page, expanding the table, or changing its filter. Hover highlights do not move the map. Google Maps and source links open a separate tab without selecting the row. Printed pages include expanded feature tables.

Invalid JSON displays a local error. Invalid layers are omitted while valid siblings remain visible. Empty collections and unlocated features are accepted. Coordinates must be finite longitude/latitude values; polygon rings must be closed. Technical details go to the browser console. Feature text is rendered as text, and source URLs accept only HTTP or HTTPS.

For development, `npm test` builds the template and tests normalization, the table, actual Leaflet interactions, failure handling, and the generated HTML. GeoMap modules live in `src/geomap/`; the build inlines their bundle and CSS into `dist/template.html`. Run `npm run build:template` after editing them to refresh the editable root `index.html` page's bundle.
78 changes: 78 additions & 0 deletions examples/geomap.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
# GeoMap example

This illustrative dataset demonstrates places, routes, and a boundary. The route lines are examples, not navigation tracks.

Select a name in the table to focus the map. Map clicks stay on the map. Point rows also have a small Google Maps link.

```geomap
{
"layers": [
{
"id": "places",
"name": "Points of interest",
"role": "poi",
"source": { "url": "https://www.openstreetmap.org/" },
"geojson": {
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"properties": { "name": "Valldemossa", "category": "settlement", "routeSection": "Core", "note": "Village reference point." },
"geometry": { "type": "Point", "coordinates": [2.62300, 39.71080] }
},
{
"type": "Feature",
"properties": { "name": "Illustrative viewpoint", "category": "viewpoint", "routeSection": "Detour" },
"geometry": { "type": "Point", "coordinates": [2.641, 39.721] }
}
]
}
},
{
"id": "route",
"name": "Example route",
"role": "route",
"geojson": {
"type": "Feature",
"properties": { "name": "Illustrative route", "description": "One table entry for the entire line." },
"geometry": { "type": "LineString", "coordinates": [[2.623, 39.7108], [2.632, 39.716], [2.647, 39.719]] }
}
},
{
"id": "detour",
"name": "Example detour",
"role": "route",
"geojson": {
"type": "Feature",
"properties": { "name": "Illustrative detour", "route_section": "Detour" },
"geometry": { "type": "MultiLineString", "coordinates": [[[2.632, 39.716], [2.641, 39.721]], [[2.641, 39.721], [2.647, 39.719]]] }
}
},
{
"id": "boundary",
"name": "Example boundary",
"role": "boundary",
"geojson": {
"type": "Feature",
"properties": { "name": "Illustrative area" },
"geometry": { "type": "Polygon", "coordinates": [[[2.629, 39.71], [2.648, 39.71], [2.648, 39.725], [2.629, 39.71]]] }
}
}
]
}
```

Multiple maps have independent layers and selection:

```geomap
{
"layers": [
{
"id": "single-point",
"name": "Single point",
"role": "poi",
"geojson": { "type": "Point", "coordinates": [2.62300, 39.71080] }
}
]
}
```
9 changes: 6 additions & 3 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
/* Standard scrollbar properties override the custom parts unless both are auto. */
@supports selector(::-webkit-scrollbar){.primary{scrollbar-width:auto;scrollbar-color:auto}.primary::-webkit-scrollbar-button{display:none;width:0;height:0}}
</style>
<link rel="stylesheet" href="src/geomap/geomap.css">
</head>
<body>
<a class="skip" href="#content">Skip to content</a>
Expand Down Expand Up @@ -805,6 +806,7 @@

</script>
<script src="https://cdn.jsdelivr.net/npm/markdown-it@14.1.0/dist/markdown-it.min.js"></script>
<script src="dist/geomap.js"></script>
<script>
'use strict';
const $ = id => document.getElementById(id);
Expand Down Expand Up @@ -862,6 +864,7 @@
}
// Extension point: add future transformations here. Navigation and TOC are core UI.
const contentEnhancements=[];
let geomaps;
function enhanceContent(article){for(const enhance of contentEnhancements)enhance(article);}
class HashRouter {
constructor(model,onRoute){this.model=model;this.onRoute=onRoute;this.positions=new Map();this.current=null;this.lastPages=new Map();}
Expand All @@ -883,15 +886,15 @@
start(){
const homePage=this.model.pages[0];$('document-title').textContent=this.model.title;$('document-title').href=homePage.route;const sidebarLayout=document.documentElement.dataset.layout==='sidebar';$('primary').hidden=sidebarLayout;document.documentElement.classList.toggle('sidebar-layout',sidebarLayout);$('secondary').setAttribute('aria-label',sidebarLayout?'Document navigation':'Pages');if('ResizeObserver'in window)new ResizeObserver(entries=>document.documentElement.style.setProperty('--header',entries[0].target.offsetHeight+'px')).observe(document.querySelector('.header'));$('status').hidden=true;$('layout').hidden=false;
$('menu').onclick=()=>$('drawer').showModal();$('close-menu').onclick=()=>$('drawer').close();$('drawer').addEventListener('click',e=>{if(e.target===$('drawer')){const r=$('drawer').getBoundingClientRect();if(e.clientX<r.left||e.clientX>r.right||e.clientY<r.top||e.clientY>r.bottom)$('drawer').close();}if(e.target.closest('a'))$('drawer').close();});
window.addEventListener('beforeprint',()=>{$('print-content').innerHTML=`<h1>${escapeHTML(this.model.title)}</h1>`+this.model.sections.map(s=>`<section><h2>${escapeHTML(s.title)}</h2>${s.pages.map(p=>`<article><h3>${escapeHTML(p.title)}</h3>${this.renderer.render(p)}</article>`).join('')}</section>`).join('');});window.addEventListener('afterprint',()=>{$('print-content').replaceChildren();});this.router.start();
window.addEventListener('beforeprint',()=>{$('print-content').innerHTML=`<h1>${escapeHTML(this.model.title)}</h1>`+this.model.sections.map(s=>`<section><h2>${escapeHTML(s.title)}</h2>${s.pages.map(p=>`<article><h3>${escapeHTML(p.title)}</h3>${this.renderer.render(p)}</article>`).join('')}</section>`).join('');geomaps?.enhance($('print-content'),{interactive:false});});window.addEventListener('afterprint',()=>{geomaps?.dispose($('print-content'));$('print-content').replaceChildren();});this.router.start();
}
render(page,anchor,initial){
const s=page.section;document.title=page.title+' · '+this.model.title+' | Pageleaf';
$('primary').innerHTML=this.model.sections.map(section=>`<a href="${(this.router.lastPages.get(section.slug)||section.pages[0]).route}"${section===s?' aria-current="true"':''}>${escapeHTML(section.title)}</a>`).join('');
const sidebarLayout=document.documentElement.dataset.layout==='sidebar';$('secondary').innerHTML=sidebarLayout?this.model.sections.map(section=>`<section class="sidebar-group"><h3 class="sidebar-group-title">${escapeHTML(section.title)}</h3>${section.pages.length>1?section.pages.map(p=>this.link(p,p.title,page)).join(''):this.link(section.pages[0],section.pages[0].kind==='overview'?'Overview':section.pages[0].title,page)}</section>`).join(''):s.pages.map(p=>this.link(p,p.title,page)).join('');
$('mobile-nav').innerHTML=this.model.sections.map(section=>`<h3>${escapeHTML(section.title)}</h3>${section.pages.map(p=>this.link(p,p.title,page)).join('')}`).join('');
$('page-title').textContent=page.title;
$('article').innerHTML=this.renderer.render(page);enhanceContent($('article'));
geomaps?.dispose($('article'));$('article').innerHTML=this.renderer.render(page);enhanceContent($('article'));
const toc=page.toc.map(h=>`<a href="${page.route}/${h.id}" data-level="${h.level}">${escapeHTML(h.title)}</a>`).join('')||`<a href="${page.route}">${escapeHTML(page.title)}</a>`;$('toc').querySelector('nav').innerHTML=toc;$('mobile-toc').querySelector('nav').innerHTML=toc;$('toc').style.visibility=toc?'visible':'hidden';$('mobile-toc').hidden=!toc;$('mobile-toc').style.display=toc?'':'none';
const index=this.model.pages.indexOf(page);$('pager').innerHTML=[index>0?`<a href="${this.model.pages[index-1].route}"><small>← Previous</small>${escapeHTML(this.model.pages[index-1].title)}</a>`:'',index<this.model.pages.length-1?`<a href="${this.model.pages[index+1].route}"><small>Next →</small>${escapeHTML(this.model.pages[index+1].title)}</a>`:''].join('');
this.observer?.disconnect();if('IntersectionObserver'in window){this.observer=new IntersectionObserver(entries=>{const visible=entries.filter(e=>e.isIntersecting);if(visible.length)this.markHeading(visible[0].target.id.slice(8));},{rootMargin:'-140px 0px -55% 0px'});page.toc.forEach(h=>this.observer.observe(document.getElementById('heading-'+h.id)));}this.markHeading(anchor||page.toc[0]?.id);
Expand All @@ -901,7 +904,7 @@
}
try{const theme=localStorage.getItem('pageleaf-theme');if(['paper','midnight','grove','ocean','contrast'].includes(theme))document.documentElement.dataset.theme=theme;}catch{}
$('theme').value=document.documentElement.dataset.theme;$('theme').onchange=e=>{document.documentElement.dataset.theme=e.target.value;try{localStorage.setItem('pageleaf-theme',e.target.value);}catch{}};
if(!window.markdownit){$('status').textContent='The Markdown library could not load. Connect to the internet and reload this file to try again.';}else{try{const md=window.markdownit({html:false,linkify:true,typographer:true});const model=new DocumentParser(md).parse(readMarkdown());new DocumentViewer(model,new MarkdownRenderer(md,model)).start();}catch(error){$('status').hidden=false;$('status').textContent='This document could not be opened: '+error.message;console.error(error);}}
if(!window.markdownit){$('status').textContent='The Markdown library could not load. Connect to the internet and reload this file to try again.';}else{try{const md=window.markdownit({html:false,linkify:true,typographer:true});geomaps=PageleafGeoMap.install(md);contentEnhancements.push(article=>geomaps.enhance(article));const model=new DocumentParser(md).parse(readMarkdown());new DocumentViewer(model,new MarkdownRenderer(md,model)).start();}catch(error){$('status').hidden=false;$('status').textContent='This document could not be opened: '+error.message;console.error(error);}}
</script>
</body>
</html>
Loading
Loading