Skip to content

Commit 8b1aa9a

Browse files
committed
add a qr code to share map urls
1 parent e0ac763 commit 8b1aa9a

11 files changed

Lines changed: 210 additions & 35 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
All notable changes to this project will be documented in this file.
44

5+
## 2026-10
6+
7+
* Share map links with QR code
8+
59
## 2026-09
610

711
* Option to organize geojson features into multiple layers, drag&drop features between layers

‎app/assets/stylesheets/application.css‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,12 @@ html {
7979
background-color: var(--color-dark-moss-green);
8080
}
8181

82+
/* the body background must cover the viewport, else the html background shows below short pages
83+
and below the map while it fades in */
84+
body {
85+
min-height: 100dvh;
86+
}
87+
8288
/* View transition */
8389
::view-transition-group(root) {
8490
animation-duration: 0.5s;

‎app/assets/stylesheets/modals.css‎

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -763,7 +763,7 @@ body.map-mode-rw .ro-only {
763763

764764
/* Larger touch targets on mobile */
765765
.copy-link {
766-
min-width: 2rem;
766+
min-width: 1.75rem;
767767
min-height: 2rem;
768768
display: inline-flex;
769769
align-items: center;
@@ -884,3 +884,36 @@ body.map-mode-rw .ro-only {
884884
height: 100%;
885885
object-fit: contain;
886886
}
887+
888+
.copy-link.qr-link i {
889+
font-size: 1.2rem;
890+
}
891+
892+
#qr-viewer {
893+
max-width: 100%;
894+
max-height: 100%;
895+
padding: 0;
896+
border: none;
897+
background: transparent;
898+
text-align: center;
899+
}
900+
901+
#qr-viewer::backdrop {
902+
background: rgb(0 0 0 / 85%);
903+
}
904+
905+
#qr-viewer svg {
906+
display: block;
907+
width: min(90vw, 80vh);
908+
height: auto;
909+
}
910+
911+
#qr-viewer .qr-caption {
912+
margin: 1rem 0 0;
913+
color: #fff;
914+
font-size: 1.5rem;
915+
}
916+
917+
#qr-viewer .qr-label {
918+
color: rgb(255 255 255 / 75%);
919+
}

‎app/javascript/controllers/map/share_controller.js‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { copyToClipboard } from 'helpers/clipboard'
55
import { mapProperties } from 'maplibre/map'
66

77
export default class extends Controller {
8+
static targets = ['qrViewer', 'qrCode', 'qrCaption', 'qrLabel']
9+
810
connect () {
911
// initializeMaplibreProperties is not yet called yet when rendering _share.haml
1012
let props = mapProperties || window.gon.map_properties
@@ -74,9 +76,71 @@ export default class extends Controller {
7476
}).catch((error) => console.log('Error sharing', error))
7577
}
7678

79+
async showQr (e) {
80+
e.preventDefault()
81+
const { url: path, label } = e.currentTarget.dataset
82+
const url = window.location.origin + path
83+
const { default: qrcode } = await import('qrcode-generator')
84+
// level H restores up to 30% of the code, the logo in the center covers less.
85+
// Version 7 (45 modules) makes the fixed size corner eyes smaller relative to the code,
86+
// longer urls fall back to the smallest version that fits
87+
const make = (version) => {
88+
const qr = qrcode(version, 'H')
89+
qr.addData(url)
90+
qr.make()
91+
return qr
92+
}
93+
let qr
94+
try { qr = make(7) } catch { qr = make(0) }
95+
this.qrCodeTarget.innerHTML = qrSvg(qr)
96+
this.qrCaptionTarget.textContent = (mapProperties || window.gon.map_properties).name || ''
97+
this.qrLabelTarget.textContent = label
98+
this.qrViewerTarget.showModal()
99+
}
100+
101+
closeQr () {
102+
this.qrViewerTarget.close()
103+
}
104+
105+
// the share modal closes on esc as well, keep it open below the qr code
106+
stopEsc (e) {
107+
if (this.qrViewerTarget.open) { e.stopPropagation() }
108+
}
109+
77110
copyEmbedCode (e) {
78111
e.preventDefault()
79112
const embedCode = this.element.querySelector('.embed-code').value.trim()
80113
copyToClipboard(embedCode, window.__('Embed code copied'), e.currentTarget)
81114
}
82115
}
116+
117+
// Mapforge style: round dots, rounded finder eyes in the logo colors, logo in the center.
118+
// The dots touch, a gap between them breaks decoders like jsQR at some render sizes
119+
function qrSvg (qr) {
120+
const n = qr.getModuleCount()
121+
const quiet = 3
122+
const logo = Math.round(n * 0.3)
123+
const logoStart = (n - logo) / 2
124+
const isEye = (r, c) => (r < 7 || r >= n - 7) && (c < 7 || c >= n - 7) && !(r >= n - 7 && c >= n - 7)
125+
const isLogo = (r, c) => [r, c].every((v) => v >= logoStart - 0.5 && v < logoStart + logo + 0.5)
126+
127+
let dots = ''
128+
for (let r = 0; r < n; r++) {
129+
for (let c = 0; c < n; c++) {
130+
if (qr.isDark(r, c) && !isEye(r, c) && !isLogo(r, c)) {
131+
dots += `<circle cx="${c + 0.5}" cy="${r + 0.5}" r="0.52"/>`
132+
}
133+
}
134+
}
135+
const eyes = [[0, 0], [n - 7, 0], [0, n - 7]].map(([x, y]) =>
136+
`<rect x="${x + 0.5}" y="${y + 0.5}" width="6" height="6" rx="2" fill="none" stroke="#4D7A9C"/>` +
137+
`<rect x="${x + 2}" y="${y + 2}" width="3" height="3" rx="1" fill="#B85E1E"/>`
138+
).join('')
139+
const center = n / 2
140+
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="${-quiet} ${-quiet} ${n + 2 * quiet} ${n + 2 * quiet}">` +
141+
`<rect x="${-quiet}" y="${-quiet}" width="${n + 2 * quiet}" height="${n + 2 * quiet}" rx="3" fill="#fff"/>` +
142+
`<g fill="#354A51">${dots}</g>${eyes}` +
143+
`<circle cx="${center}" cy="${center}" r="${logo / 2 + 0.5}" fill="#fff"/>` +
144+
`<image href="/logo/sticker/mapforge-sticker-256.png" x="${logoStart}" y="${logoStart}" width="${logo}" height="${logo}"/>` +
145+
'</svg>'
146+
}

‎app/views/maps/modals/_share.haml‎

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,22 @@
44
.map-modal-header
55
%h3.mapforge-font= _("Share Map")
66

7+
%dialog#qr-viewer{ 'aria-label': _("QR code"),
8+
data: { "map--share-target": "qrViewer",
9+
action: "keydown.esc@document:capture->map--share#stopEsc click->map--share#closeQr" } }
10+
%button.modal-close-button{ type: "button" } &times;
11+
.qr-code{ data: { "map--share-target": "qrCode" } }
12+
%p.qr-caption.mapforge-font{ data: { "map--share-target": "qrCaption" } }
13+
%p.qr-label{ data: { "map--share-target": "qrLabel" } }
14+
715
.map-modal-content.inline-scroll
816

917
.feature-section-card.pt-3
1018
- if @map_mode == 'rw' || map_owner?
1119
- if map_owner? && !@map.playground?
1220
.row.mb-3
1321
.col-12
14-
.d-flex.gap-2.align-items-center
22+
.d-flex.gap-0.gap-sm-2.align-items-center
1523
%button#share-ownership-link.form-button.btn.btn-blue.flex-grow-1{ type: "button" }
1624
=link_to(map_path(@map.private_id, join: true), data: { turbo: false, action: "click->map--share#nativeShare" }) do
1725
- if @map.owners.any?
@@ -20,36 +28,45 @@
2028
- if owner.image
2129
= image_tag avatar_url(owner.image, 24), class: 'map-avatar', title: owner.name || owner.email, data: { "toggle": 'tooltip', 'bs-placement': 'top' }, referrerpolicy: 'no-referrer'
2230
%i.bi.bi-shield-fill-check.me-2
23-
= _("Share ownership link")
31+
%span.d-none.d-sm-inline= _("Share ownership link")
32+
%span.d-sm-none= _("Ownership link")
2433
%span.permission-badge.badge-owner.ms-2= _("Owner")
2534
%a.link.text-muted.copy-link{ data: { action: "click->map--share#copyOwnershipLink", "toggle": 'tooltip', 'bs-placement': 'top' }, title: _("Copy link to clipboard") }
26-
%i.bi.bi-copy.me-1
35+
%i.bi.bi-copy.me-sm-1
36+
%a.link.text-muted.copy-link.qr-link{ data: { action: "click->map--share#showQr", url: map_path(@map.private_id, join: true), label: _("Ownership link"), "toggle": 'tooltip', 'bs-placement': 'top' }, title: _("Show QR code") }
37+
%i.bi.bi-qr-code
2738
%small.text-muted.mt-2.flex-center.center
2839
= _("Map owners have this map listed in their account with full permissions.")
2940

3041
.row.mb-3
3142
.col-12
32-
.d-flex.gap-2.align-items-center
43+
.d-flex.gap-0.gap-sm-2.align-items-center
3344
%button#share-edit-link.form-button.btn.btn-blue.flex-grow-1{ type: "button" }
3445
=link_to(map_path(@map.private_id), data: { turbo: false, action: "click->map--share#nativeShare" }) do
3546
%i.bi.bi-pencil-square.me-2
36-
= _("Share edit link")
47+
%span.d-none.d-sm-inline= _("Share edit link")
48+
%span.d-sm-none= _("Edit link")
3749
%span.permission-badge.badge-edit.ms-2= _("Can Edit")
3850
%a.link.text-muted.copy-link{ data: { action: "click->map--share#copyEditLink", "toggle": 'tooltip', 'bs-placement': 'top' }, title: _("Copy link to clipboard") }
39-
%i.bi.bi-copy.me-1
51+
%i.bi.bi-copy.me-sm-1
52+
%a.link.text-muted.copy-link.qr-link{ data: { action: "click->map--share#showQr", url: map_path(@map.private_id), label: _("Edit link"), "toggle": 'tooltip', 'bs-placement': 'top' }, title: _("Show QR code") }
53+
%i.bi.bi-qr-code
4054
%small.text-muted.mt-2.flex-center.center
4155
= _("With this link, users can modify the map, add layers, and make changes.")
4256

4357
.row.mb-3
4458
.col-12
45-
.d-flex.gap-2.align-items-center
59+
.d-flex.gap-0.gap-sm-2.align-items-center
4660
%button#share-view-link.form-button.btn.btn-blue.flex-grow-1{ type: "button" }
4761
=link_to(map_path(id: @map.public_id), data: { turbo: false, action: "click->map--share#nativeShare" }) do
4862
%i.bi.bi-eye-fill.me-2
49-
= _("Share view link")
63+
%span.d-none.d-sm-inline= _("Share view link")
64+
%span.d-sm-none= _("View link")
5065
%span.permission-badge.badge-view.ms-2= _("Read Only")
5166
%a.link.text-muted.copy-link{ data: { action: "click->map--share#copyViewLink", "toggle": 'tooltip', 'bs-placement': 'top' }, title: _("Copy link to clipboard") }
52-
%i.bi.bi-copy.me-1
67+
%i.bi.bi-copy.me-sm-1
68+
%a.link.text-muted.copy-link.qr-link{ data: { action: "click->map--share#showQr", url: map_path(id: @map.public_id), label: _("View link"), "toggle": 'tooltip', 'bs-placement': 'top' }, title: _("Show QR code") }
69+
%i.bi.bi-qr-code
5370
%small.text-muted.mt-2.flex-center.center
5471
= _("Users can explore the map but cannot modify it.")
5572

‎config/importmap.rb‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,8 @@
4848
pin "marked", preload: false # @18.0.14
4949
# https://github.com/cure53/DOMPurify, strips scripts and event handlers from rendered markdown
5050
pin "dompurify", preload: false # @3.4.16
51+
# https://github.com/kazuhikoarase/qrcode-generator, from https://esm.sh/qrcode-generator@2.0.4?bundle
52+
pin "qrcode-generator", preload: false # @2.0.4
5153

5254
# https://github.com/mapbox/mapbox-gl-draw
5355
# Unminified + locally patched, see the header of the vendored file

0 commit comments

Comments
 (0)