first commit

This commit is contained in:
Christian Lawson-Perfect 2025-02-09 19:55:34 +00:00
commit 5d69d2cad7
15 changed files with 56893 additions and 0 deletions

4
.gitignore vendored Normal file
View file

@ -0,0 +1,4 @@
.make.*
error.txt
data
elm-stuff

2
.watchmakerc Normal file
View file

@ -0,0 +1,2 @@
extensions:
- .elm

11
Makefile Normal file
View file

@ -0,0 +1,11 @@
DIRNAME=$(notdir $(CURDIR))
ELMS=$(wildcard src/*.elm)
app.js: src/App.elm $(ELMS)
-elm make $< --output=$@ 2> error.txt
@cat error.txt
upload: app.js index.html style.css
rsync -avz . clpland:~/domains/somethingorotherwhatever.com/html/$(DIRNAME)
@echo "Uploaded to https://somethingorotherwhatever.com/$(DIRNAME)"

6452
app.js Normal file

File diff suppressed because it is too large Load diff

17
cgi-bin/save_data.py Executable file
View file

@ -0,0 +1,17 @@
#!/usr/bin/python3
import cgi
import cgitb
from pathlib import Path
cgitb.enable()
form = cgi.FieldStorage()
print('Content-Type: text/plain\n')
content = form.getfirst('content')
with open(Path('..') / 'data' / 'markers.json', 'w') as f:
f.write(content)
print(content)

25
elm.json Normal file
View file

@ -0,0 +1,25 @@
{
"type": "application",
"source-directories": [
"src"
],
"elm-version": "0.19.1",
"dependencies": {
"direct": {
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm-community/list-extra": "8.7.0"
},
"indirect": {
"elm/time": "1.0.0",
"elm/url": "1.0.0",
"elm/virtual-dom": "1.0.3"
}
},
"test-dependencies": {
"direct": {},
"indirect": {}
}
}

46989
emoji_metadata.json Normal file

File diff suppressed because it is too large Load diff

30
index.html Normal file
View file

@ -0,0 +1,30 @@
<!DOCTYPE HTML>
<html>
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1.0">
<title>Elm app by clp</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script src="https://unpkg.com/protomaps-leaflet@4.0.1/dist/protomaps-leaflet.js"></script>
</head>
<body>
<header>
<h1>Elm app by clp</h1>
</header>
<main>
<p>This is an app which will either load succesfully, and you'll wonder whether you saw this text at all, or fail ignominiously, showing you only this text.</p>
<p>On balance of probabilities: I'm sorry I couldn't be bothered to make this work for you.</p>
</main>
<footer>Made by <a href="https://somethingorotherwhatever.com">clp</a></footer>
<script src="app.js"></script>
<script src="load-app.js" type="module"></script>
</body>
</html>

194
load-app.js Normal file
View file

@ -0,0 +1,194 @@
import show_error from './show-error.mjs';
import * as marked from './marked.js';
console.clear();
window.marked = marked;
class MarkdownElement extends HTMLElement {
constructor() {
super();
}
connectedCallback() {
const shadowRoot = this.attachShadow({mode:'open'});
const markdown_changed = () => {
const html = marked.parse(this.textContent);
shadowRoot.innerHTML = html;
for(let a of shadowRoot.querySelectorAll('a')) {
a.setAttribute('target','_blank');
}
}
const observer = new MutationObserver(markdown_changed);
observer.observe(this, {characterData: true, subtree: true});
markdown_changed();
}
}
customElements.define('mark-down', MarkdownElement);
class LeafletElement extends HTMLElement {
constructor() {
super();
}
addStylesheet(url) {
const linkElem = document.createElement("link");
linkElem.setAttribute("rel", "stylesheet");
linkElem.setAttribute("href", url);
this.shadowRoot.append(linkElem);
}
connectedCallback() {
const shadowRoot = this.attachShadow({mode:'open'});
this.addStylesheet("https://unpkg.com/leaflet@1.9.4/dist/leaflet.css");
this.addStylesheet('map.css');
const div = this.div = document.createElement('div');
div.style.height = '100%';
shadowRoot.append(div);
const map = this.map = L.map(div);
this.markers = [];
this.update_view();
this.update_markers();
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19,
attribution: '&copy; <a href="http://www.openstreetmap.org/copyright">OpenStreetMap</a>'
}).addTo(map);
map.on('move', () => {
const {lat,lng} = map.getCenter();
})
map.on('click', e => {
const ce = new CustomEvent('mapclick', {detail: {latlng: e.latlng}});
this.dispatchEvent(ce);
})
map.on('move', e => {
this.dispatchEvent(new CustomEvent('mapmove'));
})
}
static get observedAttributes() { return ['lat', 'lon', 'markers'] };
update_view() {
const centre = this.getAttribute('centre') == 'true';
const home = ['lat','lon'].map(a => parseFloat(this.getAttribute(a)) || 0);
if(centre) {
this.map.setView(home, this.map.getZoom() || 13);
}
}
update_markers() {
this.markers.forEach(m => {
m.marker.remove();
})
const data = JSON.parse(this.getAttribute('markers'));
this.markers = data.map(({id,pos,icon}) => {
const marker = L.marker(
pos,
{
icon: L.divIcon({html: `<span id="${id}">${icon}</span>`}),
iconSize: [20,20]
}
);
marker.addTo(this.map);
marker.on('click', e => {
this.dispatchEvent(new CustomEvent('markerclick', {detail: {id}}));
})
return {pos, marker};
});
}
attributeChangedCallback(name, oldValue, newValue) {
if(!this.map) {
return;
}
switch(name) {
case 'lat':
case 'lon':
case 'centre':
this.update_view();
break;
case 'markers':
this.update_markers();
break;
}
}
set html(value) {
this.shadowRoot.innerHTML = value;
}
}
customElements.define('leaflet-map', LeafletElement);
let opfs = await navigator.storage.getDirectory();
async function init_app() {
const compilation_error = await show_error;
if(compilation_error) {
return;
}
let markers = [];
try {
markers = await (await fetch('data/markers.json')).json();
} catch(e) {
try {
const fh = await opfs.getFileHandle('markers.json');
const f = await fh.getFile();
markers = JSON.parse(await f.text())
} catch(e) {
}
}
const emoji = await (await fetch('emoji_metadata.json')).json();
const app = Elm.App.init({node: document.body, flags: {emoji, markers}});
const params = new URLSearchParams(location.search);
navigator.geolocation.watchPosition(
(r) => {
app.ports.receive_position.send(r.coords);
},
(e) => console.error(e),
{enableHighAccuracy: true}
);
const send_value_handlers = {
save: async ({markers}) => {
const f = await opfs.getFileHandle('markers.json', {create:true});
const w = await f.createWritable();
await w.write(JSON.stringify(markers));
await w.close();
const fd = new FormData();
fd.set('content', JSON.stringify(markers));
fetch('cgi-bin/save_data.py', {
method: 'POST',
body: fd
})
}
}
app.ports.send_value.subscribe(msg => {
send_value_handlers[msg.type](msg);
});
}
init_app();

28
map.css Normal file
View file

@ -0,0 +1,28 @@
.leaflet-div-icon {
margin: 0 !important;
width: revert !important;
height: revert !important;
background: none;
border: none;
& span {
--icon-size: 1.6rem;
background: white;
border: thin solid black;
font-size: calc(0.7 * var(--icon-size));
-webkit-text-stroke: 1px blue;
border-radius: 50%;
line-height: var(--icon-size);
width: var(--icon-size) !important;
height: var(--icon-size) !important;
text-align: center;
margin-left: calc(-0.5 * var(--icon-size)) !important;
margin-top: calc(-0.5 * var(--icon-size)) !important;
display: block;
&#current-position {
background: black;
border: none;
}
}
}

2568
marked.js Normal file

File diff suppressed because it is too large Load diff

21
show-error.mjs Normal file
View file

@ -0,0 +1,21 @@
export default fetch('/error.txt').then(r=>{
if(r.ok) {
return r.text();
} else {
throw('');
}
}).then(text => {
if(!text) {
return false;
}
document.body.innerHTML = '';
const error_show = document.createElement('pre');
error_show.setAttribute('id','build-error');
error_show.style.background = 'black';
error_show.style.color = 'white';
error_show.style.padding = '1em';
error_show.style['font-size'] = '16px';
error_show.textContent = text;
document.body.appendChild(error_show);
return true;
}).catch(e => false);

426
src/App.elm Normal file
View file

@ -0,0 +1,426 @@
port module App exposing (..)
import Browser
import Dict exposing (Dict)
import Html as H exposing (Html)
import Html.Attributes as HA
import Html.Events as HE
import Json.Decode as JD
import Json.Encode as JE
import LatLonDistance exposing (lat_lon_distance, LatLon)
import List.Extra as LE
import Tuple exposing (pair, first, second)
port receive_position : (JD.Value -> msg) -> Sub msg
port send_value : JD.Value -> Cmd msg
main = Browser.document
{ init = init
, update = update
, subscriptions = subscriptions
, view = view
}
type alias Marker =
{ pos : LatLon
, icon : String
, name : String
, note : String
}
type Selection
= NoSelection
| SelectedPosition LatLon
| SelectedMarker Int
| EditingMarker Int Marker
blank_marker =
{ pos = {lat = 0, lon = 0}
, icon = "📌"
, name = ""
, note = ""
}
type MapCentre
= NoCentre
| CurrentPositionCentre
| PositionCentre LatLon
type alias Model =
{ markers : List Marker
, emoji : List Emoji
, current_position : LatLon
, selection : Selection
, new_marker : Marker
, accuracy : Float
, centre : MapCentre
}
init_model =
{ markers = []
, emoji = []
, current_position = {lat = 55.05155870729228, lon = -1.4652193740914812}
, selection = NoSelection
, new_marker = blank_marker
, accuracy = 0
, centre = CurrentPositionCentre
}
type Msg
= ReceivePosition JD.Value
| MapClicked LatLon
| MarkerClicked Int
| AddMarker
| UpdateNewMarker Marker
| UpdateExistingMarker Int Marker
| EditMarker Int Marker
| RemoveMarker Int
| ClickCurrentPosition
type alias Emoji =
{ emoji : String
, description : String
}
decode_emoji =
JD.map2 Emoji
(JD.field "emoji" JD.string)
(JD.field "description" JD.string)
type alias Flags =
{ emoji : List Emoji
, markers : List Marker
}
decode_flags =
JD.map2 Flags
(JD.field "emoji" (JD.list decode_emoji))
(JD.field "markers" (JD.list decode_marker))
init : (JD.Value) -> (Model, Cmd msg)
init vflags =
(case JD.decodeValue decode_flags vflags of
Err _ -> init_model
Ok flags -> { init_model | emoji = flags.emoji, markers = flags.markers }
) |> nocmd
nocmd m = (m, Cmd.none)
save : Model -> (Model, Cmd Msg)
save model =
(model
, send_value
<| JE.object
[ ("type", JE.string "save")
, ("markers", JE.list identity <| List.indexedMap (\i m -> encode_marker (String.fromInt i) m) model.markers)
]
)
update msg model =
case msg of
ReceivePosition v -> case JD.decodeValue decode_position v of
Ok (pos, accuracy) -> { model | current_position = pos, accuracy = accuracy } |> nocmd
_ -> model |> nocmd
MapClicked pos -> case model.selection of
NoSelection ->
let
m = model.new_marker
in
{ model | selection = SelectedPosition pos, new_marker = { m | pos = pos } } |> nocmd
_ -> { model | selection = NoSelection } |> nocmd
MarkerClicked i ->
let
marker = LE.getAt i model.markers
pos = marker |> Maybe.map .pos
centre = case pos of
Just p -> PositionCentre p
Nothing -> model.centre
in
{ model | selection = SelectedMarker i, centre = centre } |> nocmd
AddMarker -> add_marker model |> save
UpdateNewMarker marker -> { model | new_marker = marker } |> nocmd
UpdateExistingMarker i marker -> { model | markers = LE.setAt i marker model.markers, selection = SelectedMarker i } |> save
EditMarker i marker -> { model | selection = EditingMarker i marker } |> nocmd
RemoveMarker i -> { model | markers = LE.removeAt i model.markers, selection = NoSelection } |> save
ClickCurrentPosition -> { model | centre = CurrentPositionCentre, selection = NoSelection } |> nocmd
add_marker model = case model.selection of
SelectedPosition pos ->
let
nm = model.new_marker
marker = { nm | pos = pos }
in
{ model | markers = marker::model.markers, selection = SelectedMarker 0, new_marker = blank_marker }
_ -> model
-- For coordinates from the geolocation API
decode_latlon : JD.Decoder LatLon
decode_latlon =
JD.map2 LatLon
(JD.field "latitude" JD.float)
(JD.field "longitude" JD.float)
-- For coordinates from leafletjs
decode_latlng : JD.Decoder LatLon
decode_latlng =
JD.map2 LatLon
(JD.field "lat" JD.float)
(JD.field "lng" JD.float)
decode_position : JD.Decoder (LatLon, Float)
decode_position = JD.map2 pair decode_latlon (JD.field "accuracy" JD.float)
decode_map_click =
JD.at ["detail", "latlng"] decode_latlng
|> JD.map MapClicked
encode_latlng : LatLon -> JE.Value
encode_latlng pos =
JE.object
[ ("lat", JE.float pos.lat)
, ("lng", JE.float pos.lon)
]
decode_marker : JD.Decoder Marker
decode_marker =
JD.map4 Marker
(JD.field "pos" decode_latlng)
(JD.field "icon" JD.string)
(JD.field "name" JD.string)
(JD.field "note" JD.string)
encode_marker : String -> Marker -> JD.Value
encode_marker id marker =
JE.object
[ ("id", JE.string id)
, ("pos", encode_latlng marker.pos)
, ("icon", JE.string marker.icon)
, ("note", JE.string marker.note)
, ("name", JE.string marker.name)
]
splitAt : String -> String -> (String, String)
splitAt sep str =
if str == "" then ("","") else
if String.left (String.length sep) str == sep then
("", String.dropLeft (String.length sep) str)
else
let
(a,b) = splitAt sep (String.dropLeft 1 str)
in
((String.left 1 str)++a, b)
decode_marker_click =
JD.at ["detail","id"] JD.string
|> JD.andThen
(\id ->
let
(l,r) = splitAt "-" id
in
case id of
"current-position" -> JD.succeed ClickCurrentPosition
_ -> case l of
"marker" -> case String.toInt r of
Just i -> JD.succeed (MarkerClicked i)
Nothing -> JD.fail <| "Invalid int "++r
_ -> JD.fail <| "Invalid marker type "++l
)
subscriptions model =
Sub.batch
[ receive_position ReceivePosition
]
display_distance : Float -> String
display_distance d =
let
digits = max 1 (floor (logBase 10 d))
magnitude = (10^digits)
in
if digits < 4 then (d/(toFloat magnitude) |> round |> (*) magnitude |> String.fromInt) ++ "m"
else ((d/1000) |> round |> String.fromInt) ++ "km"
string_from_coords : LatLon -> String
string_from_coords c = (String.fromFloat c.lat) ++ "," ++ (String.fromFloat c.lon)
marker_link label i m =
H.a
[ HE.onClick (MarkerClicked i)
, HA.href <| "#marker-"++(String.fromInt i)
]
[ H.text label ]
space = H.text " "
view : Model -> Browser.Document Msg
view model =
let
new_marker = model.new_marker
selected_marker : List (String, Marker)
selected_marker = case model.selection of
SelectedPosition pos -> [("",{ new_marker | pos = pos })]
_ -> []
existing_markers : List (String, Marker)
existing_markers = List.indexedMap (\i m -> ("marker-"++(String.fromInt i), m)) model.markers
current_position_marker = [("current-position", { blank_marker | icon = "😀", pos = model.current_position })]
markers =
(current_position_marker ++ existing_markers ++ selected_marker)
|> JE.list (\(id,m) -> encode_marker id m)
|> JE.encode 0
edit_marker_form marker update_msg submit extra_elements =
H.form
[ HE.onSubmit submit
, HA.id "marker-form"
, HA.class "marker-detail"
]
([ H.p [] [H.text <| Debug.toString marker.pos]
, H.p
[]
[ H.label [ HA.for "marker-name" ] [ H.text "Name" ]
, H.input
[ HA.type_ "text"
, HA.id "marker-name"
, HA.value marker.name
, HE.onInput (\s -> update_msg {marker | name = s })
]
[]
, H.label [ HA.for "marker-icon" ] [ H.text "Icon" ]
, H.input
[ HA.type_ "text"
, HA.id "marker-icon"
, HA.list "emoji"
, HA.value marker.icon
, HE.onInput (\s -> update_msg {marker | icon = s })
]
[]
]
, H.p
[]
[ H.label [ HA.for "marker-note" ] [ H.text "Note" ]
, H.textarea
[ HA.id "marker-note"
, HA.value marker.note
, HE.onInput (\s -> update_msg {marker | note = s })
]
[]
]
, H.datalist
[ HA.id "emoji" ]
(model.emoji |> List.map (\e -> H.option [HA.value e.emoji] [H.text <| e.emoji ++ " " ++ e.description]))
]++extra_elements)
form = case model.selection of
SelectedPosition pos ->
edit_marker_form
model.new_marker
UpdateNewMarker
AddMarker
[ H.p [] [H.button [ HA.type_ "submit" ] [ H.text "Add a marker here" ]]
]
SelectedMarker i -> case LE.getAt i model.markers of
Just marker ->
H.div
[ HA.class "marker-detail" ]
[ H.h2
[]
[ H.button
[ HA.type_ "button"
, HE.onClick (EditMarker i marker)
]
[ H.text "Edit" ]
, space
, H.span [ HA.class "icon" ] [H.text marker.icon ]
, space
, H.text marker.name
]
, H.node "mark-down"
[]
[ H.text marker.note ]
]
Nothing -> H.div [] [H.text ""]
EditingMarker i marker ->
edit_marker_form
marker
(EditMarker i)
(UpdateExistingMarker i marker)
[ H.p
[]
[ H.button [ HA.type_ "submit" ] [ H.text "Update" ]
, space
, H.button [ HA.type_ "button", HE.onClick (RemoveMarker i) ] [ H.text "Delete" ]
, space
, marker_link "Cancel" i marker
]
]
NoSelection ->
H.div
[ HA.id "menu"
, HA.class "marker-detail"
]
[ H.h1 [] [ H.text "Closest markers" ]
, H.ul
[]
(List.map (\(i,m) ->
H.li []
[ marker_link (m.icon ++ " " ++ m.name) i m ]
)
(closest_markers |> List.take 3)
)
]
closest_markers =
model.markers
|> List.indexedMap pair
|> List.sortBy (second >> .pos >> lat_lon_distance model.current_position)
selection_str = case model.selection of
NoSelection -> "none"
SelectedPosition _ -> "position"
SelectedMarker _ -> "viewing-marker"
EditingMarker _ _ -> "editing-marker"
(centre_mode, centre) = case model.centre of
NoCentre -> (False, model.current_position)
CurrentPositionCentre -> (True, model.current_position)
PositionCentre pos -> (True, pos)
in
{
title = "CLP's map",
body =
[ H.main_
[ HA.attribute "data-selection" selection_str
]
[ form
, H.node "leaflet-map"
[ HA.attribute "markers" <| markers
, HA.attribute "lat" <| String.fromFloat centre.lat
, HA.attribute "lon" <| String.fromFloat centre.lon
, HA.attribute "centre" <| if centre_mode then "true" else "false"
, HE.on "mapclick" decode_map_click
, HE.on "markerclick" decode_marker_click
]
[]
]
]
}

50
src/LatLonDistance.elm Normal file
View file

@ -0,0 +1,50 @@
module LatLonDistance exposing (lat_lon_distance, LatLon)
type alias LatLon = { lat : Float, lon : Float }
lat_lon_distance : LatLon -> LatLon -> Float
lat_lon_distance p1 p2 =
let
a = 6378137.0
b = 6356752.314245
f = 1 / 298.257223563
lat1 = degrees p1.lat
lat2 = degrees p2.lat
lon1 = degrees p1.lon
lon2 = degrees p2.lon
dlon = (lon2 - lon1)
tanU1 = (1-f) * (tan lat1)
cosU1 = 1 / (sqrt (1 + tanU1 * tanU1))
sinU1 = tanU1 * cosU1
tanU2 = (1-f) * (tan lat2)
cosU2 = 1 / (sqrt (1 + tanU2*tanU2))
sinU2 = tanU2 * cosU2
approx lon =
let
sinlon = sin lon
coslon = cos lon
sinSqsigma = (cosU2*sinlon) * (cosU2*sinlon) + (cosU1*sinU2-sinU1*cosU2*coslon) ^ 2
sinsigma = sqrt sinSqsigma
cossigma = sinU1*sinU2 + cosU1*cosU2*coslon
sigma = atan2 sinsigma cossigma
sinalpha = cosU1 * cosU2 * sinlon / sinsigma
cosSqalpha = 1 - sinalpha*sinalpha
cos2sigma_m = cossigma - 2*sinU1*sinU2/cosSqalpha
c = f/16*cosSqalpha*(4+f*(4-3*cosSqalpha))
lon_ = dlon + (1-c) * f * sinalpha * (sigma + c*sinsigma*(cos2sigma_m+c*cossigma*(-1+2*cos2sigma_m*cos2sigma_m)))
uSq = cosSqalpha * (a*a - b*b) / (b*b)
biga = 1 + uSq/16384*(4096+uSq*(-768+uSq*(320-175*uSq)))
bigb = uSq/1024 * (256+uSq*(-128+uSq*(74-47*uSq)))
deltasigma = bigb*sinsigma*(cos2sigma_m+bigb/4*(cossigma*(-1+2*cos2sigma_m*cos2sigma_m)-bigb/6*cos2sigma_m*(-3+4*sinsigma*sinsigma)*(-3+4*cos2sigma_m*cos2sigma_m)))
s = b*biga*(sigma-deltasigma)
in
if abs (lon - lon_) > 1e-12 then (approx lon_) else s
in
approx dlon

76
style.css Normal file
View file

@ -0,0 +1,76 @@
body {
margin: 0;
}
:is(h1,h2,h3):first-child {
margin-top: 0;
}
body > main {
display: grid;
grid-template:
"text" auto
"map" 1fr
;
width: 100svw;
height: 100svh;
margin: 0;
}
.marker-detail {
padding: 0 1em;
max-height: 70svh;
overflow: auto;
}
#marker-form {
& label {
margin: 0 0.5em;
}
& input {
margin: 0.25em;
}
& textarea {
width: 100%;
height: 5em;
resize: none;
overflow: auto;
}
& #marker-icon {
width: 5em;
}
}
@media (orientation: landscape) {
body > main {
grid-template:
"text map" 1fr
/ min(20em,50svw) 1fr
;
}
.marker-detail {
max-height: 100svh;
}
#marker-form {
& textarea {
height: 70svh;
}
}
}
#menu {
& li ~ li {
margin-top: 0.5em;
}
}
leaflet-map {
display: block;
height: 100%;
}