From 063d8ef9eaf874a941f4459e831057dd0a1b7ddd Mon Sep 17 00:00:00 2001 From: Joris Date: Tue, 5 Jul 2022 21:55:41 +0200 Subject: Rewrite in TS --- src/Color.ml | 38 -- src/Lib/CSV.ml | 76 ---- src/Lib/ContextMenu.ml | 40 --- src/Lib/Dom/Document.ml | 20 -- src/Lib/Dom/Element.ml | 51 --- src/Lib/Dom/Event.ml | 17 - src/Lib/Dom/H.ml | 65 ---- src/Lib/Dom/HA.ml | 43 --- src/Lib/Dom/HE.ml | 13 - src/Lib/Dom/History.ml | 2 - src/Lib/Dom/Location.ml | 7 - src/Lib/Dom/Window.ml | 2 - src/Lib/File.ml | 21 -- src/Lib/FontAwesome.ml | 788 ------------------------------------------ src/Lib/Fun.ml | 2 - src/Lib/Leaflet.ml | 89 ----- src/Lib/Modal.ml | 25 -- src/Lib/Option.ml | 9 - src/Lib/String.ml | 35 -- src/Lib/URI.ml | 2 - src/Main.ml | 3 - src/State.ml | 119 ------- src/View/Button.ml | 19 - src/View/Form.ml | 65 ---- src/View/Form/Autocomplete.ml | 80 ----- src/View/Layout.ml | 9 - src/View/Map.ml | 131 ------- src/View/Map/Icon.ml | 32 -- src/View/Map/Marker.ml | 105 ------ src/lib/autoComplete.ts | 115 ++++++ src/lib/base.ts | 32 ++ src/lib/button.ts | 29 ++ src/lib/color.ts | 36 ++ src/lib/contextMenu.ts | 35 ++ src/lib/dom.ts | 6 + src/lib/form.ts | 54 +++ src/lib/h.ts | 31 ++ src/lib/icons.ts | 66 ++++ src/lib/layout.ts | 15 + src/lib/modal.ts | 28 ++ src/main.ts | 3 + src/map.ts | 131 +++++++ src/marker.ts | 171 +++++++++ src/markerForm.ts | 116 +++++++ src/serialization.ts | 44 +++ src/serialization/utils.ts | 9 + src/serialization/v0.ts | 122 +++++++ src/state.ts | 65 ++++ src/types/leaflet.d.ts | 95 +++++ 49 files changed, 1203 insertions(+), 1908 deletions(-) delete mode 100644 src/Color.ml delete mode 100644 src/Lib/CSV.ml delete mode 100644 src/Lib/ContextMenu.ml delete mode 100644 src/Lib/Dom/Document.ml delete mode 100644 src/Lib/Dom/Element.ml delete mode 100644 src/Lib/Dom/Event.ml delete mode 100644 src/Lib/Dom/H.ml delete mode 100644 src/Lib/Dom/HA.ml delete mode 100644 src/Lib/Dom/HE.ml delete mode 100644 src/Lib/Dom/History.ml delete mode 100644 src/Lib/Dom/Location.ml delete mode 100644 src/Lib/Dom/Window.ml delete mode 100644 src/Lib/File.ml delete mode 100644 src/Lib/FontAwesome.ml delete mode 100644 src/Lib/Fun.ml delete mode 100644 src/Lib/Leaflet.ml delete mode 100644 src/Lib/Modal.ml delete mode 100644 src/Lib/Option.ml delete mode 100644 src/Lib/String.ml delete mode 100644 src/Lib/URI.ml delete mode 100644 src/Main.ml delete mode 100644 src/State.ml delete mode 100644 src/View/Button.ml delete mode 100644 src/View/Form.ml delete mode 100644 src/View/Form/Autocomplete.ml delete mode 100644 src/View/Layout.ml delete mode 100644 src/View/Map.ml delete mode 100644 src/View/Map/Icon.ml delete mode 100644 src/View/Map/Marker.ml create mode 100644 src/lib/autoComplete.ts create mode 100644 src/lib/base.ts create mode 100644 src/lib/button.ts create mode 100644 src/lib/color.ts create mode 100644 src/lib/contextMenu.ts create mode 100644 src/lib/dom.ts create mode 100644 src/lib/form.ts create mode 100644 src/lib/h.ts create mode 100644 src/lib/icons.ts create mode 100644 src/lib/layout.ts create mode 100644 src/lib/modal.ts create mode 100644 src/main.ts create mode 100644 src/map.ts create mode 100644 src/marker.ts create mode 100644 src/markerForm.ts create mode 100644 src/serialization.ts create mode 100644 src/serialization/utils.ts create mode 100644 src/serialization/v0.ts create mode 100644 src/state.ts create mode 100644 src/types/leaflet.d.ts (limited to 'src') diff --git a/src/Color.ml b/src/Color.ml deleted file mode 100644 index d2f74c4..0000000 --- a/src/Color.ml +++ /dev/null @@ -1,38 +0,0 @@ -let from_sRGB sRGB = - if sRGB <= 0.03928 then - sRGB /. 12.92 - else - ((sRGB +. 0.055) /. 1.055) ** 2.4 - -type rgb = - { r: float - ; g: float - ; b: float - } - -(* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#relativeluminancedef *) -let relativeLuminance (c: rgb) = - 0.2126 *. from_sRGB (c.r /. 255.) +. 0.7152 *. from_sRGB (c.g /. 255.) +. 0.0722 *. from_sRGB (c.b /. 255.) - -(* https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrastratio *) -let contrast_ratio (c1: rgb) (c2: rgb) = - let rl1 = relativeLuminance c1 in - let rl2 = relativeLuminance c2 in - - if (rl1 > rl2) then - (rl1 +. 0.05) /. (rl2 +. 0.05) - else - (rl2 +. 0.05) /. (rl1 +. 0.05) - -let from_raw color = - let get_opt = function | Some x -> x | None -> raise (Invalid_argument "Option.get") in - let div = H.div [| HA.style ("color: " ^ color) |] [| |] in - let body = Document.query_selector_unsafe "body" in - let () = Element.append_child body div in - let rgb = [%raw {| window.getComputedStyle(div).color |}] in - let () = Element.remove_child body div in - let xs = Js.String.split ", " (get_opt (Js.String.splitByRe [%re "/[()]/"] rgb).(1)) in - { r = Js.Float.fromString xs.(0) - ; g = Js.Float.fromString xs.(1) - ; b = Js.Float.fromString xs.(2) - } diff --git a/src/Lib/CSV.ml b/src/Lib/CSV.ml deleted file mode 100644 index f0366f7..0000000 --- a/src/Lib/CSV.ml +++ /dev/null @@ -1,76 +0,0 @@ -let to_string lines = - let - cell_to_string cell = - if Js.String.includes "\"" cell then - "\"" ^ (Js.String.replaceByRe [%re "/\"/g"] "\"\"" cell) ^ "\"" - else - cell - in let - line_to_string line = - line - |> Js.Array.map cell_to_string - |> Js.Array.joinWith "," - in lines - |> Js.Array.map line_to_string - |> Js.Array.joinWith "\n" - -let parse str = - let lines = [| |] in - let current_line = ref [| |] in - let current_cell = ref "" in - let in_quote = ref false in - let i = ref 0 in - let l = Js.String.length str in - let () = while !i < l do - let cc = Js.String.get str !i in - let nc = Js.String.get str (!i + 1) in - let () = - if !in_quote && cc == "\"" && nc == "\"" then - let () = current_cell := !current_cell ^ cc in - i := !i + 1 - else if cc == "\"" then - in_quote := not !in_quote - else if not !in_quote && cc == "," then - let _ = Js.Array.push !current_cell !current_line in - current_cell := "" - else if not !in_quote && ((cc == "\r" && nc == "\n") || cc == "\n" || cc == "\r") then - let _ = Js.Array.push !current_cell !current_line in - let _ = Js.Array.push !current_line lines in - let _ = current_line := [| |] in - current_cell := "" - else - current_cell := !current_cell ^ cc - in - i := !i + 1 - done - in - let _ = - if Js.String.length !current_cell > 0 then - let _ = Js.Array.push !current_cell !current_line in () - else - () - in - let _ = - if Js.Array.length !current_line > 0 then - let _ = Js.Array.push !current_line lines in () - else - () - in - lines - -let to_dicts lines = - let res = [| |] in - let () = - if Js.Array.length lines > 0 then - let header = Js.Array.unsafe_get lines 0 in - for i = 1 to Js.Array.length lines - 1 do - let line = Js.Array.unsafe_get lines i in - let dict = Js.Dict.empty() in - let () = - Js.Array.forEachi - (fun key j -> Js.Dict.set dict key (Js.Array.unsafe_get line j)) - header - in - ignore (Js.Array.push dict res) - done - in res diff --git a/src/Lib/ContextMenu.ml b/src/Lib/ContextMenu.ml deleted file mode 100644 index b9ed7d4..0000000 --- a/src/Lib/ContextMenu.ml +++ /dev/null @@ -1,40 +0,0 @@ -let px f = - Js.Float.toString f ^ "px" - -type entry = - { label: string - ; action: unit -> unit - } - -let show mouse_event actions = - let menu = - H.div - [| HA.id "g-ContextMenu" - ; HA.style ("left: " ^ (px (Event.page_x mouse_event)) ^ "; top: " ^ (px (Event.page_y mouse_event))) - |] - (Js.Array.map - (fun entry -> - H.div - [| HA.class_ "g-ContextMenu__Entry" - ; HE.on_click (fun _ -> entry.action ()) - |] - [| H.text entry.label |]) - actions) - in - let () = Element.append_child Document.body menu in - - (* Remove on click or context menu *) - let _ = - Js.Global.setTimeout - (fun _ -> - let rec f = (fun _ -> - let () = Element.remove_child Document.body menu in - let () = Element.remove_event_listener Document.body "click" f in - Element.remove_event_listener Document.body "contextmenu" f) - in - let () = Element.add_event_listener Document.body "click" f in - Element.add_event_listener Document.body "contextmenu" f - ) - 0 - in - () diff --git a/src/Lib/Dom/Document.ml b/src/Lib/Dom/Document.ml deleted file mode 100644 index 46f983a..0000000 --- a/src/Lib/Dom/Document.ml +++ /dev/null @@ -1,20 +0,0 @@ -external body : Dom.element = "body" - [@@bs.val] [@@bs.scope "document"] - -external create_element : string -> Dom.element = "createElement" - [@@bs.val] [@@bs.scope "document"] - -external create_element_ns : string -> string -> Dom.element = "createElementNS" - [@@bs.val] [@@bs.scope "document"] - -external query_selector : string -> Dom.element Js.Nullable.t = "querySelector" - [@@bs.val] [@@bs.scope "document"] - -let query_selector_unsafe id = - query_selector id |> Js.Nullable.toOption |> Js.Option.getExn - -external create_text_node : string -> Dom.element = "createTextNode" - [@@bs.val] [@@bs.scope "document"] - -external location : Location.location = "location" - [@@bs.val] [@@bs.scope "document"] diff --git a/src/Lib/Dom/Element.ml b/src/Lib/Dom/Element.ml deleted file mode 100644 index feb6003..0000000 --- a/src/Lib/Dom/Element.ml +++ /dev/null @@ -1,51 +0,0 @@ -external set_value : Dom.element -> string -> unit = "value" - [@@bs.set] - -external value : Dom.element -> string = "value" - [@@bs.get] - -external set_attribute : Dom.element -> string -> string -> unit = "setAttribute" - [@@bs.send] - -external set_class_name : Dom.element -> string -> unit = "className" - [@@bs.set] - -external add_event_listener : Dom.element -> string -> (Dom.event -> unit) -> unit - = "addEventListener" - [@@bs.send] - -external remove_event_listener : Dom.element -> string -> (Dom.event -> unit) -> unit - = "removeEventListener" - [@@bs.send] - -external append_child : Dom.element -> Dom.element -> unit = "appendChild" - [@@bs.send] - -external first_child : Dom.element -> Dom.element Js.Nullable.t = "firstChild" - [@@bs.get] - -external remove_child : Dom.element -> Dom.element -> unit = "removeChild" - [@@bs.send] - -external click : Dom.element -> unit = "click" - [@@bs.send] - -let remove_first_child element = - match Js.toOption (first_child element) with - | Some child -> - let () = remove_child element child in - true - | _ -> false - -let rec remove_children element = - if remove_first_child element then remove_children element else () - -let mount_on base element = - let () = remove_children base in - append_child base element - -external files : Dom.element -> string Js.Array.t = "files" - [@@bs.get] - -external focus : Dom.element -> unit = "focus" - [@@bs.send] diff --git a/src/Lib/Dom/Event.ml b/src/Lib/Dom/Event.ml deleted file mode 100644 index 5a9790f..0000000 --- a/src/Lib/Dom/Event.ml +++ /dev/null @@ -1,17 +0,0 @@ -external prevent_default : Dom.event -> unit = "preventDefault" - [@@bs.send] - -external stop_propagation : Dom.event -> unit = "stopPropagation" - [@@bs.send] - -external target : Dom.event -> Dom.element = "target" - [@@bs.get] - -external related_target : Dom.event -> Dom.element Js.Nullable.t = "relatedTarget" - [@@bs.get] - -external page_x : Dom.mouseEvent -> float = "pageX" - [@@bs.get] - -external page_y : Dom.mouseEvent -> float = "pageY" - [@@bs.get] diff --git a/src/Lib/Dom/H.ml b/src/Lib/Dom/H.ml deleted file mode 100644 index 7213daf..0000000 --- a/src/Lib/Dom/H.ml +++ /dev/null @@ -1,65 +0,0 @@ -(* Element creation *) - -type attribute = - | TextAttr of string * string - | EventAttr of string * (Dom.event -> unit) - -let h tag attributes children = - let element = - if tag == "svg" || tag == "path" then - Document.create_element_ns "http://www.w3.org/2000/svg" tag - else Document.create_element tag - in - let () = - Js.Array.forEach - (fun attr -> - match attr with - | TextAttr (name, value) -> - Element.set_attribute element name value - - | EventAttr (name, eventListener) -> - Element.add_event_listener element name eventListener) - attributes - in - let () = - Js.Array.forEach - (fun child -> Element.append_child element child) - children - in - element - -(* Node creation *) - -let text = Document.create_text_node - -let div = h "div" - -let span = h "span" - -let header = h "header" - -let button = h "button" - -let section = h "section" - -let svg = h "svg" - -let path = h "path" - -let form = h "form" - -let label = h "label" - -let input = h "input" - -let textarea = h "textarea" - -let i = h "i" - -let a = h "a" - -let h1 = h "h1" - -let h2 = h "h2" - -let h3 = h "h3" diff --git a/src/Lib/Dom/HA.ml b/src/Lib/Dom/HA.ml deleted file mode 100644 index ce02f2a..0000000 --- a/src/Lib/Dom/HA.ml +++ /dev/null @@ -1,43 +0,0 @@ -let concat xs ys = - let partition_class = - Js.Array.reduce - (fun (class_acc, rest_acc) z -> - match z with - | H.TextAttr ("class", c) -> (class_acc ^ " " ^ c, rest_acc) - | _ -> (class_acc, Js.Array.concat [| z |] rest_acc) - ) - ("", [| |]) - in - let (xs_class, xs_rest) = partition_class xs in - let (ys_class, ys_rest) = partition_class ys in - let rest = Js.Array.concat xs_rest ys_rest in - if xs_class == "" && ys_class == "" then - rest - else - Js.Array.concat [| H.TextAttr ("class", xs_class ^ " " ^ ys_class) |] rest - -(* Attribute creation *) - -let id v = H.TextAttr ("id", v) - -let class_ v = H.TextAttr ("class", v) - -let viewBox v = H.TextAttr ("viewBox", v) - -let d v = H.TextAttr ("d", v) - -let type_ v = H.TextAttr ("type", v) - -let min_ v = H.TextAttr ("min", v) - -let value v = H.TextAttr ("value", v) - -let for_ v = H.TextAttr ("for", v) - -let style v = H.TextAttr ("style", v) - -let href v = H.TextAttr ("href", v) - -let autocomplete v = H.TextAttr ("autocomplete", v) - -let download v = H.TextAttr ("download", v) diff --git a/src/Lib/Dom/HE.ml b/src/Lib/Dom/HE.ml deleted file mode 100644 index 03d2386..0000000 --- a/src/Lib/Dom/HE.ml +++ /dev/null @@ -1,13 +0,0 @@ -(* Event listeners *) - -let on_click f = H.EventAttr ("click", f) - -let on_input f = H.EventAttr ("input", f) - -let on_submit f = H.EventAttr ("submit", f) - -let on_blur f = H.EventAttr ("blur", f) - -let on_change f = H.EventAttr ("change", f) - -let on_focus f = H.EventAttr ("focus", f) diff --git a/src/Lib/Dom/History.ml b/src/Lib/Dom/History.ml deleted file mode 100644 index ce7a877..0000000 --- a/src/Lib/Dom/History.ml +++ /dev/null @@ -1,2 +0,0 @@ -external push_state : string -> string -> string -> unit -> unit = "pushState" - [@@bs.val] [@@bs.scope "history"] diff --git a/src/Lib/Dom/Location.ml b/src/Lib/Dom/Location.ml deleted file mode 100644 index 2c58705..0000000 --- a/src/Lib/Dom/Location.ml +++ /dev/null @@ -1,7 +0,0 @@ -external set : Dom.element -> string -> unit = "location" - [@@bs.set] - -type location - -external hash : location -> string = "hash" - [@@bs.get] diff --git a/src/Lib/Dom/Window.ml b/src/Lib/Dom/Window.ml deleted file mode 100644 index 3abc921..0000000 --- a/src/Lib/Dom/Window.ml +++ /dev/null @@ -1,2 +0,0 @@ -external window : Dom.element = "window" - [@@bs.val] diff --git a/src/Lib/File.ml b/src/Lib/File.ml deleted file mode 100644 index d3597e7..0000000 --- a/src/Lib/File.ml +++ /dev/null @@ -1,21 +0,0 @@ -let download filename content = - let a = - H.a - [| HA.href ("data:text/plain;charset=utf-8," ^ URI.encode content) - ; HA.download filename - ; HA.style "display:none" - |] - [| |] - in - let () = Element.append_child Document.body a in - let () = Element.click a in - Element.remove_child Document.body a - -external reader : unit -> Dom.element = "FileReader" - [@@bs.new] - -external read_as_text : Dom.element -> string -> unit = "readAsText" - [@@bs.send] - -external result : Dom.element -> string = "result" - [@@bs.get] diff --git a/src/Lib/FontAwesome.ml b/src/Lib/FontAwesome.ml deleted file mode 100644 index daaf954..0000000 --- a/src/Lib/FontAwesome.ml +++ /dev/null @@ -1,788 +0,0 @@ -let icons = - [| "500px" - ; "address-book" - ; "address-book-o" - ; "address-card" - ; "address-card-o" - ; "adjust" - ; "adn" - ; "align-center" - ; "align-justify" - ; "align-left" - ; "align-right" - ; "amazon" - ; "ambulance" - ; "american-sign-language-interpreting" - ; "anchor" - ; "android" - ; "angellist" - ; "angle-double-down" - ; "angle-double-left" - ; "angle-double-right" - ; "angle-double-up" - ; "angle-down" - ; "angle-left" - ; "angle-right" - ; "angle-up" - ; "apple" - ; "archive" - ; "area-chart" - ; "arrow-circle-down" - ; "arrow-circle-left" - ; "arrow-circle-o-down" - ; "arrow-circle-o-left" - ; "arrow-circle-o-right" - ; "arrow-circle-o-up" - ; "arrow-circle-right" - ; "arrow-circle-up" - ; "arrow-down" - ; "arrow-left" - ; "arrow-right" - ; "arrow-up" - ; "arrows" - ; "arrows-alt" - ; "arrows-h" - ; "arrows-v" - ; "asl-interpreting" - ; "assistive-listening-systems" - ; "asterisk" - ; "at" - ; "audio-description" - ; "automobile" - ; "backward" - ; "balance-scale" - ; "ban" - ; "bandcamp" - ; "bank" - ; "bar-chart" - ; "bar-chart-o" - ; "barcode" - ; "bars" - ; "bath" - ; "bathtub" - ; "battery" - ; "battery-0" - ; "battery-1" - ; "battery-2" - ; "battery-3" - ; "battery-4" - ; "battery-empty" - ; "battery-full" - ; "battery-half" - ; "battery-quarter" - ; "battery-three-quarters" - ; "bed" - ; "beer" - ; "behance" - ; "behance-square" - ; "bell" - ; "bell-o" - ; "bell-slash" - ; "bell-slash-o" - ; "bicycle" - ; "binoculars" - ; "birthday-cake" - ; "bitbucket" - ; "bitbucket-square" - ; "bitcoin" - ; "black-tie" - ; "blind" - ; "bluetooth" - ; "bluetooth-b" - ; "bold" - ; "bolt" - ; "bomb" - ; "book" - ; "bookmark" - ; "bookmark-o" - ; "braille" - ; "briefcase" - ; "btc" - ; "bug" - ; "building" - ; "building-o" - ; "bullhorn" - ; "bullseye" - ; "bus" - ; "buysellads" - ; "cab" - ; "calculator" - ; "calendar" - ; "calendar-check-o" - ; "calendar-minus-o" - ; "calendar-o" - ; "calendar-plus-o" - ; "calendar-times-o" - ; "camera" - ; "camera-retro" - ; "car" - ; "caret-down" - ; "caret-left" - ; "caret-right" - ; "caret-square-o-down" - ; "caret-square-o-left" - ; "caret-square-o-right" - ; "caret-square-o-up" - ; "caret-up" - ; "cart-arrow-down" - ; "cart-plus" - ; "cc" - ; "cc-amex" - ; "cc-diners-club" - ; "cc-discover" - ; "cc-jcb" - ; "cc-mastercard" - ; "cc-paypal" - ; "cc-stripe" - ; "cc-visa" - ; "certificate" - ; "chain" - ; "chain-broken" - ; "check" - ; "check-circle" - ; "check-circle-o" - ; "check-square" - ; "check-square-o" - ; "chevron-circle-down" - ; "chevron-circle-left" - ; "chevron-circle-right" - ; "chevron-circle-up" - ; "chevron-down" - ; "chevron-left" - ; "chevron-right" - ; "chevron-up" - ; "child" - ; "chrome" - ; "circle" - ; "circle-o" - ; "circle-o-notch" - ; "circle-thin" - ; "clipboard" - ; "clock-o" - ; "clone" - ; "close" - ; "cloud" - ; "cloud-download" - ; "cloud-upload" - ; "cny" - ; "code" - ; "code-fork" - ; "codepen" - ; "codiepie" - ; "coffee" - ; "cog" - ; "cogs" - ; "columns" - ; "comment" - ; "comment-o" - ; "commenting" - ; "commenting-o" - ; "comments" - ; "comments-o" - ; "compass" - ; "compress" - ; "connectdevelop" - ; "contao" - ; "copy" - ; "copyright" - ; "creative-commons" - ; "credit-card" - ; "credit-card-alt" - ; "crop" - ; "crosshairs" - ; "css3" - ; "cube" - ; "cubes" - ; "cut" - ; "cutlery" - ; "dashboard" - ; "dashcube" - ; "database" - ; "deaf" - ; "deafness" - ; "dedent" - ; "delicious" - ; "desktop" - ; "deviantart" - ; "diamond" - ; "digg" - ; "dollar" - ; "dot-circle-o" - ; "download" - ; "dribbble" - ; "drivers-license" - ; "drivers-license-o" - ; "dropbox" - ; "drupal" - ; "edge" - ; "edit" - ; "eercast" - ; "eject" - ; "ellipsis-h" - ; "ellipsis-v" - ; "empire" - ; "envelope" - ; "envelope-o" - ; "envelope-open" - ; "envelope-open-o" - ; "envelope-square" - ; "envira" - ; "eraser" - ; "etsy" - ; "eur" - ; "euro" - ; "exchange" - ; "exclamation" - ; "exclamation-circle" - ; "exclamation-triangle" - ; "expand" - ; "expeditedssl" - ; "external-link" - ; "external-link-square" - ; "eye" - ; "eye-slash" - ; "eyedropper" - ; "fa" - ; "facebook" - ; "facebook-f" - ; "facebook-official" - ; "facebook-square" - ; "fast-backward" - ; "fast-forward" - ; "fax" - ; "feed" - ; "female" - ; "fighter-jet" - ; "file" - ; "file-archive-o" - ; "file-audio-o" - ; "file-code-o" - ; "file-excel-o" - ; "file-image-o" - ; "file-movie-o" - ; "file-o" - ; "file-pdf-o" - ; "file-photo-o" - ; "file-picture-o" - ; "file-powerpoint-o" - ; "file-sound-o" - ; "file-text" - ; "file-text-o" - ; "file-video-o" - ; "file-word-o" - ; "file-zip-o" - ; "files-o" - ; "film" - ; "filter" - ; "fire" - ; "fire-extinguisher" - ; "firefox" - ; "first-order" - ; "flag" - ; "flag-checkered" - ; "flag-o" - ; "flash" - ; "flask" - ; "flickr" - ; "floppy-o" - ; "folder" - ; "folder-o" - ; "folder-open" - ; "folder-open-o" - ; "font" - ; "font-awesome" - ; "fonticons" - ; "fort-awesome" - ; "forumbee" - ; "forward" - ; "foursquare" - ; "free-code-camp" - ; "frown-o" - ; "futbol-o" - ; "gamepad" - ; "gavel" - ; "gbp" - ; "ge" - ; "gear" - ; "gears" - ; "genderless" - ; "get-pocket" - ; "gg" - ; "gg-circle" - ; "gift" - ; "git" - ; "git-square" - ; "github" - ; "github-alt" - ; "github-square" - ; "gitlab" - ; "gittip" - ; "glass" - ; "glide" - ; "glide-g" - ; "globe" - ; "google" - ; "google-plus" - ; "google-plus-circle" - ; "google-plus-official" - ; "google-plus-square" - ; "google-wallet" - ; "graduation-cap" - ; "gratipay" - ; "grav" - ; "group" - ; "h-square" - ; "hacker-news" - ; "hand-grab-o" - ; "hand-lizard-o" - ; "hand-o-down" - ; "hand-o-left" - ; "hand-o-right" - ; "hand-o-up" - ; "hand-paper-o" - ; "hand-peace-o" - ; "hand-pointer-o" - ; "hand-rock-o" - ; "hand-scissors-o" - ; "hand-spock-o" - ; "hand-stop-o" - ; "handshake-o" - ; "hard-of-hearing" - ; "hashtag" - ; "hdd-o" - ; "header" - ; "headphones" - ; "heart" - ; "heart-o" - ; "heartbeat" - ; "history" - ; "home" - ; "hospital-o" - ; "hotel" - ; "hourglass" - ; "hourglass-1" - ; "hourglass-2" - ; "hourglass-3" - ; "hourglass-end" - ; "hourglass-half" - ; "hourglass-o" - ; "hourglass-start" - ; "houzz" - ; "html5" - ; "i-cursor" - ; "id-badge" - ; "id-card" - ; "id-card-o" - ; "ils" - ; "image" - ; "imdb" - ; "inbox" - ; "indent" - ; "industry" - ; "info" - ; "info-circle" - ; "inr" - ; "instagram" - ; "institution" - ; "internet-explorer" - ; "intersex" - ; "ioxhost" - ; "italic" - ; "joomla" - ; "jpy" - ; "jsfiddle" - ; "key" - ; "keyboard-o" - ; "krw" - ; "language" - ; "laptop" - ; "lastfm" - ; "lastfm-square" - ; "leaf" - ; "leanpub" - ; "legal" - ; "lemon-o" - ; "level-down" - ; "level-up" - ; "life-bouy" - ; "life-buoy" - ; "life-ring" - ; "life-saver" - ; "lightbulb-o" - ; "line-chart" - ; "link" - ; "linkedin" - ; "linkedin-square" - ; "linode" - ; "linux" - ; "list" - ; "list-alt" - ; "list-ol" - ; "list-ul" - ; "location-arrow" - ; "lock" - ; "long-arrow-down" - ; "long-arrow-left" - ; "long-arrow-right" - ; "long-arrow-up" - ; "low-vision" - ; "magic" - ; "magnet" - ; "mail-forward" - ; "mail-reply" - ; "mail-reply-all" - ; "male" - ; "map" - ; "map-marker" - ; "map-o" - ; "map-pin" - ; "map-signs" - ; "mars" - ; "mars-double" - ; "mars-stroke" - ; "mars-stroke-h" - ; "mars-stroke-v" - ; "maxcdn" - ; "meanpath" - ; "medium" - ; "medkit" - ; "meetup" - ; "meh-o" - ; "mercury" - ; "microchip" - ; "microphone" - ; "microphone-slash" - ; "minus" - ; "minus-circle" - ; "minus-square" - ; "minus-square-o" - ; "mixcloud" - ; "mobile" - ; "mobile-phone" - ; "modx" - ; "money" - ; "moon-o" - ; "mortar-board" - ; "motorcycle" - ; "mouse-pointer" - ; "music" - ; "navicon" - ; "neuter" - ; "newspaper-o" - ; "object-group" - ; "object-ungroup" - ; "odnoklassniki" - ; "odnoklassniki-square" - ; "opencart" - ; "openid" - ; "opera" - ; "optin-monster" - ; "outdent" - ; "pagelines" - ; "paint-brush" - ; "paper-plane" - ; "paper-plane-o" - ; "paperclip" - ; "paragraph" - ; "paste" - ; "pause" - ; "pause-circle" - ; "pause-circle-o" - ; "paw" - ; "paypal" - ; "pencil" - ; "pencil-square" - ; "pencil-square-o" - ; "percent" - ; "phone" - ; "phone-square" - ; "photo" - ; "picture-o" - ; "pie-chart" - ; "pied-piper" - ; "pied-piper-alt" - ; "pied-piper-pp" - ; "pinterest" - ; "pinterest-p" - ; "pinterest-square" - ; "plane" - ; "play" - ; "play-circle" - ; "play-circle-o" - ; "plug" - ; "plus" - ; "plus-circle" - ; "plus-square" - ; "plus-square-o" - ; "podcast" - ; "power-off" - ; "print" - ; "product-hunt" - ; "puzzle-piece" - ; "qq" - ; "qrcode" - ; "question" - ; "question-circle" - ; "question-circle-o" - ; "quora" - ; "quote-left" - ; "quote-right" - ; "ra" - ; "random" - ; "ravelry" - ; "rebel" - ; "recycle" - ; "reddit" - ; "reddit-alien" - ; "reddit-square" - ; "refresh" - ; "registered" - ; "remove" - ; "renren" - ; "reorder" - ; "repeat" - ; "reply" - ; "reply-all" - ; "resistance" - ; "retweet" - ; "rmb" - ; "road" - ; "rocket" - ; "rotate-left" - ; "rotate-right" - ; "rouble" - ; "rss" - ; "rss-square" - ; "rub" - ; "ruble" - ; "rupee" - ; "s15" - ; "safari" - ; "save" - ; "scissors" - ; "scribd" - ; "search" - ; "search-minus" - ; "search-plus" - ; "sellsy" - ; "send" - ; "send-o" - ; "server" - ; "share" - ; "share-alt" - ; "share-alt-square" - ; "share-square" - ; "share-square-o" - ; "shekel" - ; "sheqel" - ; "shield" - ; "ship" - ; "shirtsinbulk" - ; "shopping-bag" - ; "shopping-basket" - ; "shopping-cart" - ; "shower" - ; "sign-in" - ; "sign-language" - ; "sign-out" - ; "signal" - ; "signing" - ; "simplybuilt" - ; "sitemap" - ; "skyatlas" - ; "skype" - ; "slack" - ; "sliders" - ; "slideshare" - ; "smile-o" - ; "snapchat" - ; "snapchat-ghost" - ; "snapchat-square" - ; "snowflake-o" - ; "soccer-ball-o" - ; "sort" - ; "sort-alpha-asc" - ; "sort-alpha-desc" - ; "sort-amount-asc" - ; "sort-amount-desc" - ; "sort-asc" - ; "sort-desc" - ; "sort-down" - ; "sort-numeric-asc" - ; "sort-numeric-desc" - ; "sort-up" - ; "soundcloud" - ; "space-shuttle" - ; "spinner" - ; "spoon" - ; "spotify" - ; "square" - ; "square-o" - ; "stack-exchange" - ; "stack-overflow" - ; "star" - ; "star-half" - ; "star-half-empty" - ; "star-half-full" - ; "star-half-o" - ; "star-o" - ; "steam" - ; "steam-square" - ; "step-backward" - ; "step-forward" - ; "stethoscope" - ; "sticky-note" - ; "sticky-note-o" - ; "stop" - ; "stop-circle" - ; "stop-circle-o" - ; "street-view" - ; "strikethrough" - ; "stumbleupon" - ; "stumbleupon-circle" - ; "subscript" - ; "subway" - ; "suitcase" - ; "sun-o" - ; "superpowers" - ; "superscript" - ; "support" - ; "table" - ; "tablet" - ; "tachometer" - ; "tag" - ; "tags" - ; "tasks" - ; "taxi" - ; "telegram" - ; "television" - ; "tencent-weibo" - ; "terminal" - ; "text-height" - ; "text-width" - ; "th" - ; "th-large" - ; "th-list" - ; "themeisle" - ; "thermometer" - ; "thermometer-0" - ; "thermometer-1" - ; "thermometer-2" - ; "thermometer-3" - ; "thermometer-4" - ; "thermometer-empty" - ; "thermometer-full" - ; "thermometer-half" - ; "thermometer-quarter" - ; "thermometer-three-quarters" - ; "thumb-tack" - ; "thumbs-down" - ; "thumbs-o-down" - ; "thumbs-o-up" - ; "thumbs-up" - ; "ticket" - ; "times" - ; "times-circle" - ; "times-circle-o" - ; "times-rectangle" - ; "times-rectangle-o" - ; "tint" - ; "toggle-down" - ; "toggle-left" - ; "toggle-off" - ; "toggle-on" - ; "toggle-right" - ; "toggle-up" - ; "trademark" - ; "train" - ; "transgender" - ; "transgender-alt" - ; "trash" - ; "trash-o" - ; "tree" - ; "trello" - ; "tripadvisor" - ; "trophy" - ; "truck" - ; "try" - ; "tty" - ; "tumblr" - ; "tumblr-square" - ; "turkish-lira" - ; "tv" - ; "twitch" - ; "twitter" - ; "twitter-square" - ; "umbrella" - ; "underline" - ; "undo" - ; "universal-access" - ; "university" - ; "unlink" - ; "unlock" - ; "unlock-alt" - ; "unsorted" - ; "upload" - ; "usb" - ; "usd" - ; "user" - ; "user-circle" - ; "user-circle-o" - ; "user-md" - ; "user-o" - ; "user-plus" - ; "user-secret" - ; "user-times" - ; "users" - ; "vcard" - ; "vcard-o" - ; "venus" - ; "venus-double" - ; "venus-mars" - ; "viacoin" - ; "viadeo" - ; "viadeo-square" - ; "video-camera" - ; "vimeo" - ; "vimeo-square" - ; "vine" - ; "vk" - ; "volume-control-phone" - ; "volume-down" - ; "volume-off" - ; "volume-up" - ; "warning" - ; "wechat" - ; "weibo" - ; "weixin" - ; "whatsapp" - ; "wheelchair" - ; "wheelchair-alt" - ; "wifi" - ; "wikipedia-w" - ; "window-close" - ; "window-close-o" - ; "window-maximize" - ; "window-minimize" - ; "window-restore" - ; "windows" - ; "won" - ; "wordpress" - ; "wpbeginner" - ; "wpexplorer" - ; "wpforms" - ; "wrench" - ; "xing" - ; "xing-square" - ; "y-combinator" - ; "y-combinator-square" - ; "yahoo" - ; "yc" - ; "yc-square" - ; "yelp" - ; "yen" - ; "yoast" - ; "youtube" - ; "youtube-play" - ; "youtube-square" - |] diff --git a/src/Lib/Fun.ml b/src/Lib/Fun.ml deleted file mode 100644 index bf1eb38..0000000 --- a/src/Lib/Fun.ml +++ /dev/null @@ -1,2 +0,0 @@ -let flip f b a = - f a b diff --git a/src/Lib/Leaflet.ml b/src/Lib/Leaflet.ml deleted file mode 100644 index 282b5b0..0000000 --- a/src/Lib/Leaflet.ml +++ /dev/null @@ -1,89 +0,0 @@ -type layer - -type map_options = - { attributionControl : bool - } - -external map : string -> map_options -> layer = "map" - [@@bs.val] [@@bs.scope "L"] - -external setView : layer -> float array -> int -> unit = "setView" - [@@bs.send] - -type event - -external on : layer -> string -> (event -> unit) -> unit = "on" - [@@bs.send] - -type lat_lng = - { lat : float; - lng : float; - } - -external original_event : event -> Dom.mouseEvent = "originalEvent" - [@@bs.get] - -external lat_lng : event -> lat_lng = "latlng" - [@@bs.get] - -external target : event -> layer = "target" - [@@bs.get] - -external get_lat_lng : layer -> unit -> lat_lng = "getLatLng" - [@@bs.send] - -external title_layer : string -> layer = "tileLayer" - [@@bs.val] [@@bs.scope "L"] - -external add_layer : layer -> layer -> unit = "addLayer" - [@@bs.send] - -external clear_layers : layer -> unit = "clearLayers" - [@@bs.send] - -external remove : layer -> unit = "remove" - [@@bs.send] - -external get_layers : layer -> unit -> layer array = "getLayers" - [@@bs.send] - -(* Fit bounds *) - -external feature_group : layer array -> layer = "featureGroup" - [@@bs.val] [@@bs.scope "L"] - -type bounds - -external get_bounds : layer -> unit -> bounds = "getBounds" - [@@bs.send] - -type fit_bounds_options = - { padding: float array - } - -external fit_bounds : layer -> bounds -> fit_bounds_options -> unit = "fitBounds" - [@@bs.send] - -(* Icon *) - -type icon - -type div_icon_input = - { className : string - ; popupAnchor : float array - ; html : Dom.element - } - -external div_icon : div_icon_input -> icon = "divIcon" - [@@bs.val] [@@bs.scope "L"] - -(* Marker *) - -type markerInput = - { title : string - ; icon : icon - ; draggable : bool - } - -external marker : lat_lng -> markerInput -> layer = "marker" - [@@bs.val] [@@bs.scope "L"] diff --git a/src/Lib/Modal.ml b/src/Lib/Modal.ml deleted file mode 100644 index 5db88cd..0000000 --- a/src/Lib/Modal.ml +++ /dev/null @@ -1,25 +0,0 @@ -let hide () = - let modal = Document.query_selector_unsafe "#g-Modal" in - Element.remove_child Document.body modal - -let show content = - let view = - H.div - [| HA.id "g-Modal" |] - [| H.div - [| HA.class_ "g-Modal__Curtain" - ; HE.on_click (fun _ -> hide ()) - |] - [| |] - ; H.div - [| HA.class_ "g-Modal__Window" |] - [| Button.raw - [| HA.class_ "g-Modal__Close" - ; HE.on_click (fun _ -> hide ()) - |] - [| H.div [| HA.class_ "fa fa-close" |] [| |] |] - ; content - |] - |] - in - Element.append_child Document.body view diff --git a/src/Lib/Option.ml b/src/Lib/Option.ml deleted file mode 100644 index 1158b96..0000000 --- a/src/Lib/Option.ml +++ /dev/null @@ -1,9 +0,0 @@ -let withDefault default opt = - match opt with - | Some v -> v - | None -> default - -let map f opt = - match opt with - | Some v -> Some (f v) - | None -> None diff --git a/src/Lib/String.ml b/src/Lib/String.ml deleted file mode 100644 index be16d0e..0000000 --- a/src/Lib/String.ml +++ /dev/null @@ -1,35 +0,0 @@ -let format_float precision f = - let str = Js.Float.toString f in - match Js.String.split "." str with - | [| a ; b |] -> a ^ "." ^ (Js.String.substring ~from:0 ~to_:precision b) - | _ -> str - -external btoa : string -> string = "btoa" - [@@bs.val] [@@bs.scope "window"] - -external atob : string -> string = "atob" - [@@bs.val] [@@bs.scope "window"] - -external unescape : string -> string = "unescape" - [@@bs.val] - -external escape : string -> string = "escape" - [@@bs.val] - -external encodeURIComponent : string -> string = "encodeURIComponent" - [@@bs.val] - -external decodeURIComponent : string -> string = "decodeURIComponent" - [@@bs.val] - -let encode str = - str - |> encodeURIComponent - |> unescape - |> btoa - -let decode str = - str - |> atob - |> escape - |> decodeURIComponent diff --git a/src/Lib/URI.ml b/src/Lib/URI.ml deleted file mode 100644 index 705bc7b..0000000 --- a/src/Lib/URI.ml +++ /dev/null @@ -1,2 +0,0 @@ -external encode : string -> string = "encodeURIComponent" - [@@bs.val] diff --git a/src/Main.ml b/src/Main.ml deleted file mode 100644 index 9216b35..0000000 --- a/src/Main.ml +++ /dev/null @@ -1,3 +0,0 @@ -let () = - let body = Document.query_selector_unsafe "body" in - Element.append_child body (Map.render ()) diff --git a/src/State.ml b/src/State.ml deleted file mode 100644 index c1cb99d..0000000 --- a/src/State.ml +++ /dev/null @@ -1,119 +0,0 @@ -type marker_state = - { pos : Leaflet.lat_lng - ; name : string - ; color : string - ; icon : string - } - -let remove state pos = - Js.Array.filter (fun m -> m.pos != pos) state - -let update state previousPos marker = - Js.Array.concat [| marker |] (remove state previousPos) - -let last_added state = - if Js.Array.length state > 0 then - Some state.(0) - else - None - -(* URL Serialization *) - -let sep = "|" - -let marker_to_string marker = - [| String.format_float 6 marker.pos.lat - ; String.format_float 6 marker.pos.lng - ; marker.name - ; marker.color - ; marker.icon - |] - |> Js.Array.joinWith sep - -let to_url_string state = - state - |> Js.Array.map marker_to_string - |> Js.Array.joinWith sep - |> String.encode - -let from_url_string str = - let (_, _, res) = Js.Array.reduce - (fun (acc_str, acc_marker, acc_state) c -> - let length = Js.Array.length acc_marker in - if c != sep then - (acc_str ^ c, acc_marker, acc_state) - else if c == sep && length < 4 then - ("", Js.Array.concat [| acc_str |] acc_marker, acc_state) - else - let marker = - { pos = - { lat = Js.Float.fromString acc_marker.(0) - ; lng = Js.Float.fromString acc_marker.(1) - } - ; name = acc_marker.(2) - ; color = acc_marker.(3) - ; icon = acc_str - } - in ("", [| |], Js.Array.concat acc_state [| marker |]) - ) - ("", [| |], [| |]) - (Js.Array.from (Js.String.castToArrayLike ((String.decode str) ^ sep))) - in res - -(* Colors *) - -let default_color = "#3f92cf" - -let colors = - Js.Array.reduce - (fun colors marker -> - if Js.Array.indexOf marker.color colors == -1 then - Js.Array.concat [| marker.color |] colors - else - colors) - [| |] - -(* CSV Serialization *) - -let lat_key = "lat" -let lng_key = "lng" -let name_key = "name" -let color_key = "color" -let icon_key = "icon" - -let to_csv_string state = - let to_csv_line marker = - [| Js.Float.toString marker.pos.lat - ; Js.Float.toString marker.pos.lng - ; marker.name - ; marker.color - ; marker.icon - |] - in let - header = - [| lat_key; lng_key; name_key; color_key; icon_key |] - in - state - |> Js.Array.map to_csv_line - |> Fun.flip Js.Array.concat [| header |] - |> CSV.to_string - -let from_dicts dicts = - Js.Array.map - (fun dict -> - (* let get key default = Js.Dict.get dict key |> Option.withDefault default in *) - { pos = - { lat = - Js.Dict.get dict lat_key - |> Option.map Js.Float.fromString - |> Option.withDefault 0.0 - ; lng = - Js.Dict.get dict lng_key - |> Option.map Js.Float.fromString - |> Option.withDefault 0.0 - } - ; name = Js.Dict.get dict name_key |> Option.withDefault "" - ; color = Js.Dict.get dict color_key |> Option.withDefault default_color - ; icon = Js.Dict.get dict icon_key |> Option.withDefault "" - }) - dicts diff --git a/src/View/Button.ml b/src/View/Button.ml deleted file mode 100644 index b4641d2..0000000 --- a/src/View/Button.ml +++ /dev/null @@ -1,19 +0,0 @@ -let raw attrs content = - H.button - (HA.concat [| HA.class_ "g-Button__Raw" |] attrs) - content - -let text attrs content = - H.button - (HA.concat [| HA.class_ "g-Button__Text" |] attrs) - content - -let action attrs content = - H.button - (HA.concat [| HA.class_ "g-Button__Action" |] attrs) - content - -let cancel attrs content = - H.button - (HA.concat [| HA.class_ "g-Button__Cancel" |] attrs) - content diff --git a/src/View/Form.ml b/src/View/Form.ml deleted file mode 100644 index cec49d6..0000000 --- a/src/View/Form.ml +++ /dev/null @@ -1,65 +0,0 @@ -let input id label attrs = - H.div - [| HA.class_ "g-Form__Field" |] - [| H.div - [| HA.class_ "g-Form__Label" |] - [| H.label - [| HA.for_ id |] - [| H.text label |] - |] - ; H.input - (HA.concat attrs [| HA.id id |]) - [| |] - |] - -let color_input default_colors id label init_value on_input = - let - input = - H.input - [| HA.id id - ; HE.on_input (fun e -> on_input (Element.value (Event.target e))) - ; HA.value init_value - ; HA.type_ "color" - |] - [| |] - in - H.div - [| HA.class_ "g-Form__Field" |] - [| H.div - [| HA.class_ "g-Form__Label" |] - [| H.label - [| HA.for_ id |] - [| H.text label |] - |] - ; Layout.line - [| |] - (default_colors - |> Js.Array.map (fun color -> - Button.raw - [| HA.class_ "g-Form__DefaultColor" - ; HA.style ("background-color: " ^ color) - ; HE.on_click (fun _ -> - let () = Element.set_value input color in - on_input color) - ; HA.type_ "button" - |] - [| |]) - |> Fun.flip Js.Array.concat [| input |]) - |] - -let textarea id label init_value on_input = - H.div - [| HA.class_ "g-Form__Field" |] - [| H.div - [| HA.class_ "g-Form__Label" |] - [| H.label - [| HA.for_ id |] - [| H.text label |] - |] - ; H.textarea - [| HA.id id - ; HA.class_ "g-Form__Textarea" - ; HE.on_input (fun e -> on_input (Element.value (Event.target e))) - |] - [| H.text init_value |] - |] diff --git a/src/View/Form/Autocomplete.ml b/src/View/Form/Autocomplete.ml deleted file mode 100644 index 98e4b43..0000000 --- a/src/View/Form/Autocomplete.ml +++ /dev/null @@ -1,80 +0,0 @@ -let search s xs = - Js.Array.filter (Js.String.includes s) xs - -let render_completion render_entry on_select entries = - H.div - [| HA.class_ "g-Autocomplete__Completion" |] - (entries - |> Js.Array.map (fun c -> - Button.raw - [| HA.class_ "g-Autocomplete__Entry" - ; HA.type_ "button" - ; HE.on_click (fun e -> - let () = Event.stop_propagation e in - let () = Event.prevent_default e in - on_select c) - |] - (render_entry c))) - -let create attrs id values render_entry on_input = - - let completion = - H.div [| |] [| |] - in - - let update_completion target value = - let entries = search value values in - Element.mount_on completion (render_completion - render_entry - (fun selected -> - let () = Element.set_value target selected in - let () = Element.remove_children completion in - on_input selected) - entries) - in - - let hide_completion () = - Element.mount_on completion (H.text "") - in - - let - input = - H.input - (HA.concat - attrs - [| HA.id id - ; HA.class_ "g-Autocomplete__Input" - ; HA.autocomplete "off" - ; HE.on_focus (fun e -> - let target = Event.target e in - let value = Element.value target in - update_completion target value) - ; HE.on_input (fun e -> - let target = Event.target e in - let value = Element.value target in - let () = update_completion target value in - on_input value) - |]) - [| |] - in - - let () = - Element.add_event_listener input "blur" (fun e -> - if Js.isNullable (Event.related_target e) then - hide_completion ()) - in - - H.div - [| HA.class_ "g-Autocomplete" |] - [| input - ; completion - ; Button.raw - [| HA.class_ "g-Autocomplete__Clear fa fa-close" - ; HA.type_ "button" - ; HE.on_click (fun _ -> - let () = on_input "" in - let () = Element.set_value input "" in - Element.focus input) - |] - [| |] - |] diff --git a/src/View/Layout.ml b/src/View/Layout.ml deleted file mode 100644 index db1e234..0000000 --- a/src/View/Layout.ml +++ /dev/null @@ -1,9 +0,0 @@ -let section attrs content = - H.div - (HA.concat attrs [| HA.class_ "g-Layout__Section" |]) - content - -let line attrs content = - H.div - (HA.concat attrs [| HA.class_ "g-Layout__Line" |]) - content diff --git a/src/View/Map.ml b/src/View/Map.ml deleted file mode 100644 index 6e2611e..0000000 --- a/src/View/Map.ml +++ /dev/null @@ -1,131 +0,0 @@ -let state_from_hash () = - let hash = Js.String.sliceToEnd ~from:1 (Location.hash Document.location) in - State.from_url_string hash - -let rec reload_from_hash state map markers focus = - let update_state new_state = - let () = History.push_state "" "" ("#" ^ State.to_url_string new_state) () in - reload_from_hash state map markers false - in - - let on_remove pos = - update_state (State.remove !state pos) in - - let on_update previousPos pos name color icon = - update_state (State.update !state previousPos { pos = pos; name = name; color = color; icon = icon }) in - - let () = - if Js.Array.length (Leaflet.get_layers markers ()) > 0 then - Leaflet.clear_layers markers - else - () - in - let () = state := state_from_hash () in - let colors = State.colors !state in - let () = - Js.Array.forEach - (fun (m: State.marker_state) -> Leaflet.add_layer markers (Marker.create on_remove on_update colors m.pos m.name m.color m.icon)) - !state - in - if focus then - if Js.Array.length (Leaflet.get_layers markers ()) > 0 then - Leaflet.fit_bounds map (Leaflet.get_bounds markers ()) { padding = [| 50.; 50. |] } - else - Leaflet.setView map [| 51.505; -0.09 |] 2 - else - () - -let mapView state map markers = - H.div - [| HA.class_ "g-Layout__Page" |] - [| H.div - [| HA.class_ "g-Layout__Header" |] - [| H.a - [| HA.class_ "g-Layout__Home" - ; HA.href "#" - |] - [| H.text "Map" |] - ; Layout.line - [| HA.class_ "g-Layout__HeaderImportExport" |] - [| H.input - [| HA.id "g-Header__ImportInput" - ; HA.type_ "file" - ; HE.on_change (fun e -> - match !map with - | Some map -> - let reader = File.reader () in - let () = Element.add_event_listener reader "load" (fun _ -> - let str = File.result reader in - let new_state = State.from_dicts (CSV.to_dicts (CSV.parse str)) in - let () = History.push_state "" "" ("#" ^ State.to_url_string new_state) () in - reload_from_hash state map markers true) - in - File.read_as_text reader ( - Js.Array.unsafe_get (Element.files (Event.target e)) 0) - | _ -> - ()) - |] - [| |] - ; H.label - [| HA.for_ "g-Header__ImportInput" - ; HA.class_ "g-Button__Text" - |] - [| H.text "Import" |] - ; Button.text - [| HE.on_click (fun _ -> File.download "map.csv" (State.to_csv_string !state)) |] - [| H.text "Export" |] - |] - |] - ; H.div - [| HA.class_ "g-Map" |] - [| H.div - [| HA.id "g-Map__Content" |] - [||] - |] - |] - -let install_map state map_ref markers = - let map = Leaflet.map "g-Map__Content" { attributionControl = false } in - let () = map_ref := Some map in - let title_layer = Leaflet.title_layer "http://{s}.tile.osm.org/{z}/{x}/{y}.png" in - let () = Leaflet.add_layer map markers in - let () = Leaflet.add_layer map title_layer in - - (* Init markers from url *) - let () = reload_from_hash state map markers true in - - (* Reload the map if the URL changes *) - let () = Element.add_event_listener Window.window "popstate" (fun _ -> - reload_from_hash state map markers true) - in - - let add_marker pos name color icon = - let new_marker = { State.pos = pos; name = name; color = color; icon = icon } in - let new_state = State.update !state pos new_marker in - let () = History.push_state "" "" ("#" ^ State.to_url_string new_state) () in - reload_from_hash state map markers false - in - - (* Context menu *) - Leaflet.on map "contextmenu" (fun event -> - ContextMenu.show - (Leaflet.original_event event) - [| { label = "Add a marker" - ; action = (fun _ -> - let pos = Leaflet.lat_lng event in - let marker = - match State.last_added !state with - | Some m -> { m with pos = pos; name = "" } - | _ -> { pos = pos; name = ""; color = "#3f92cf"; icon = "" } - in - let colors = State.colors !state in - Modal.show (Marker.form (add_marker pos) colors marker.name marker.color marker.icon)) - } - |]) - -let render () = - let state = ref (state_from_hash ()) in - let map = ref None in - let markers = Leaflet.feature_group [| |] in - let _ = Js.Global.setTimeout (fun _ -> install_map state map markers) 0 in - mapView state map markers diff --git a/src/View/Map/Icon.ml b/src/View/Map/Icon.ml deleted file mode 100644 index 8737f43..0000000 --- a/src/View/Map/Icon.ml +++ /dev/null @@ -1,32 +0,0 @@ -let create name color = - let c = Color.from_raw color in - let crBlack = Color.contrast_ratio { r = 0.; g = 0.; b = 0. } c in - let crWhite = Color.contrast_ratio { r = 255.; g = 255.; b = 255. } c in - let textCol = if crBlack > crWhite then "black" else "white" in - Leaflet.div_icon - { className = "" - ; popupAnchor = [| 0.; -34. |] - ; html = - H.div - [| HA.class_ "g-Marker" |] - [| H.div - [| HA.class_ "g-Marker__Round" - ; HA.style ("background-color: " ^ color) - |] - [| |] - ; H.div [| HA.class_ "g-Marker__PeakBorder" |] [| |] - ; H.div - [| HA.class_ "g-Marker__PeakInner" - ; HA.style ("border-top-color: " ^ color) - |] - [| |] - ; H.div - [| HA.class_ "g-Marker__Icon" |] - [| H.i - [| HA.class_ ("fa fa-" ^ name) - ; HA.style ("color: " ^ textCol) - |] - [| |] - |] - |] - } diff --git a/src/View/Map/Marker.ml b/src/View/Map/Marker.ml deleted file mode 100644 index 1c0c0d6..0000000 --- a/src/View/Map/Marker.ml +++ /dev/null @@ -1,105 +0,0 @@ -let form on_validate colors init_name init_color init_icon = - let name = ref init_name in - let color = ref init_color in - let icon = ref init_icon in - let on_validate () = - let () = on_validate !name !color !icon in - Modal.hide () - in - H.div - [| |] - [| Layout.section - [| |] - [| H.form - [| HA.class_ "g-MarkerForm" - ; HE.on_submit (fun e -> - let () = Event.prevent_default e in - on_validate ()) - |] - [| Layout.section - [| |] - [| Form.input - "g-MarkerForm__Name" - "Name" - [| HE.on_input (fun e -> name := (Element.value (Event.target e))) - ; HA.value init_name - |] - ; Form.color_input colors "g-MarkerForm__Color" "Color" init_color (fun newColor -> color := newColor) - ; H.div - [| HA.class_ "g-Form__Field" |] - [| H.div - [| HA.class_ "g-Form__Label" |] - [| H.label - [| HA.for_ "g-MarkerForm__IconInput" |] - [| H.text "Icon" |] - |] - ; let dom_icon = H.div [| HA.class_ ("fa fa-" ^ !icon) |] [| |] in - Layout.line - [| HA.class_ "g-MarkerForm__AutocompleteAndIcon" |] - [| Autocomplete.create - [| HA.value init_icon - ; HA.class_ "g-MarkerForm__Autocomplete" - |] - "g-MarkerForm__IconInput" - FontAwesome.icons - (fun icon -> - [| H.div - [| HA.class_ ("g-MarkerForm__IconEntry fa fa-" ^ icon) |] - [| |] - ; H.text icon - |]) - (fun newIcon -> - let () = icon := newIcon in - Element.set_class_name dom_icon ("fa fa-" ^ newIcon)) - ; H.div [| HA.class_ "g-MarkerForm__Icon" |] [| dom_icon |] - |] - |] - |] - ; Layout.line - [| |] - [| Button.action - [| HE.on_click (fun _ -> on_validate ()) |] - [| H.text "Save" |] - ; Button.cancel - [| HE.on_click (fun _ -> Modal.hide ()) - ; HA.type_ "button" - |] - [| H.text "Cancel" |] - |] - |] - |] - |] - - -let create on_remove on_update colors pos init_name init_color init_icon = - let marker = - Leaflet.marker pos - { title = init_name - ; icon = Icon.create init_icon init_color - ; draggable = true - } - in - - (* Context menu *) - let () = Leaflet.on marker "contextmenu" (fun event -> - ContextMenu.show - (Leaflet.original_event event) - [| { label = "Modify" - ; action = fun _ -> - Modal.show (form (on_update pos pos) colors init_name init_color init_icon) - } - ; { label = "Remove" - ; action = fun _ -> on_remove pos - } - |]) - in - - (* Move the cursor on drag *) - let () = Leaflet.on marker "dragend" (fun e -> - let newPos = Leaflet.get_lat_lng (Leaflet.target e) () in - on_update pos newPos init_name init_color init_icon) in - - let () = Leaflet.on marker "dblclick" (fun _ -> - Modal.show (form (on_update pos pos) colors init_name init_color init_icon)) in - - marker diff --git a/src/lib/autoComplete.ts b/src/lib/autoComplete.ts new file mode 100644 index 0000000..b0a79eb --- /dev/null +++ b/src/lib/autoComplete.ts @@ -0,0 +1,115 @@ +import { h, Children, concatClassName } from 'lib/h' +import * as Button from 'lib/button' + +export function create( + attrs: object, + id: string, + keys: string[], + renderEntry: (entry: string) => Element, + onInput: (value: string) => void +): Element { + const completion = h('div', {}) + + const updateCompletion = (target: EventTarget, value: string) => { + const entries = search(value, keys) + mountOn( + completion, + renderCompletion( + renderEntry, + selected => { + (target as HTMLInputElement).value = selected + completion.remove + removeChildren(completion) + onInput(selected) + }, + entries + ) + ) + } + + const input = h('input', + concatClassName( + { ...attrs, + id, + autocomplete: 'off', + onfocus: (e: Event) => { + if (e.target !== null) { + const target = e.target as HTMLInputElement + updateCompletion(target, target.value) + } + }, + oninput: (e: Event) => { + if (e.target !== null) { + const target = e.target as HTMLInputElement + updateCompletion(target, target.value) + onInput(target.value) + } + } + }, + 'g-AutoComplete__Input' + ) + ) as HTMLInputElement + + input.addEventListener('blur', (e: MouseEvent) => { + if (e.relatedTarget === null) { + removeChildren(completion) + } + }) + + return h('div', + { className: 'g-AutoComplete' }, + input, + completion, + Button.raw( + { className: 'g-AutoComplete__Clear', + type: 'button', + onclick: () => { + onInput('') + input.value = '' + input.focus() + } + }, + 'x' + ) + ) +} + +function renderCompletion( + renderEntry: (entry: string) => Element, + onSelect: (entry: string) => void, + entries: string[] +): Element { + return h('div', + { className: 'g-AutoComplete__Completion' }, + ...entries.map(c => + Button.raw( + { className: 'g-AutoComplete__Entry', + type: 'button', + onclick: (e: Event) => { + e.stopPropagation() + e.preventDefault() + onSelect(c) + } + }, + renderEntry(c) + ) + ) + ) +} + +function search(s: string, xs: string[]): string[] { + return xs.filter(x => x.includes(s)) +} + +function mountOn(base: Element, ...children: Element[]) { + removeChildren(base) + children.forEach(child => base.appendChild(child)) +} + +function removeChildren(base: Element) { + const firstChild = base.firstChild + if (firstChild !== null) { + base.removeChild(firstChild) + removeChildren(base) + } +} diff --git a/src/lib/base.ts b/src/lib/base.ts new file mode 100644 index 0000000..59c91cc --- /dev/null +++ b/src/lib/base.ts @@ -0,0 +1,32 @@ +export const b2: string[] = + '01'.split('') + +export const b16: string[] = + '0123456789abcdef'.split('') + +export const b62: string[] = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ'.split('') + +export function encode(n: bigint, charset: string[]): string { + const base = BigInt(charset.length) + + if (n == BigInt(0)) { + return '0' + } else { + var xs = [] + while (n > BigInt(0)) { + xs.push(charset[Number(n % base)]) + n = n / base + } + return xs.reverse().join('') + } +} + +export function decode(xs: string, charset: string[]): bigint { + const base = BigInt(charset.length) + + return xs + .split('') + .reverse() + .reduce((acc, x, i) => acc + (BigInt(charset.indexOf(x)) * (base ** BigInt(i))), BigInt(0)) +} diff --git a/src/lib/button.ts b/src/lib/button.ts new file mode 100644 index 0000000..794df35 --- /dev/null +++ b/src/lib/button.ts @@ -0,0 +1,29 @@ +import { h, Children, concatClassName } from 'lib/h' + +export function raw(attrs: object, ...children: Children): Element { + return h('button', + concatClassName(attrs, 'g-Button__Raw'), + ...children + ) +} + +export function text(attrs: object, ...children: Children): Element { + return h('button', + concatClassName(attrs, 'g-Button__Text'), + ...children + ) +} + +export function action(attrs: object, ...children: Children): Element { + return h('button', + concatClassName(attrs, 'g-Button__Action'), + ...children + ) +} + +export function cancel(attrs: object, ...children: Children): Element { + return h('button', + concatClassName(attrs, 'g-Button__Cancel'), + ...children + ) +} diff --git a/src/lib/color.ts b/src/lib/color.ts new file mode 100644 index 0000000..59b320d --- /dev/null +++ b/src/lib/color.ts @@ -0,0 +1,36 @@ +interface Color { + red: number, + green: number, + blue: number, +} + +export function parse(str: string): Color { + return { + red: parseInt(str.slice(1,3), 16), + green: parseInt(str.slice(3,5), 16), + blue: parseInt(str.slice(5,7), 16), + } +} + +// https://www.w3.org/TR/2008/REC-WCAG20-20081211/#contrastratio +export function contrastRatio(c1: Color, c2: Color): number { + const r1 = relativeLuminance(c1) + const r2 = relativeLuminance(c2) + + return r1 > r2 + ? (r1 + 0.05) / (r2 + 0.05) + : (r2 + 0.05) / (r1 + 0.05) +} + +function relativeLuminance(c: Color): number { + return ( + 0.2126 * fromSRGB(c.red / 255) + + 0.7152 * fromSRGB(c.green / 255) + + 0.0722 * fromSRGB(c.blue / 255)) +} + +function fromSRGB(sRGB: number): number { + return sRGB <= 0.03928 + ? sRGB / 12.92 + : Math.pow(((sRGB + 0.055) / 1.055), 2.4) +} diff --git a/src/lib/contextMenu.ts b/src/lib/contextMenu.ts new file mode 100644 index 0000000..6edd567 --- /dev/null +++ b/src/lib/contextMenu.ts @@ -0,0 +1,35 @@ +import { h } from 'lib/h' + +interface Action { + label: string, + action: () => void +} + +export function show(event: MouseEvent, actions: Action[]) { + const menu = h('div', + { id: 'g-ContextMenu', + style: `left: ${event.pageX.toString()}px; top: ${event.pageY.toString()}px` + }, + ...actions.map(({ label, action }) => + h('div', + { className: 'g-ContextMenu__Entry', + onclick: () => action() + }, + label + ) + ) + ) + + document.body.appendChild(menu) + + // Remove on click or context menu + setTimeout(() => { + const f = () => { + document.body.removeChild(menu) + document.body.removeEventListener('click', f) + document.body.removeEventListener('contextmenu', f) + } + document.body.addEventListener('click', f) + document.body.addEventListener('contextmenu', f) + }, 0) +} diff --git a/src/lib/dom.ts b/src/lib/dom.ts new file mode 100644 index 0000000..2ab4de5 --- /dev/null +++ b/src/lib/dom.ts @@ -0,0 +1,6 @@ +export function replaceChildren(parent: Element, ...newChildren: Element[]) { + while (parent.lastChild) { + parent.removeChild(parent.lastChild) + } + newChildren.forEach(c => parent.appendChild(c)) +} diff --git a/src/lib/form.ts b/src/lib/form.ts new file mode 100644 index 0000000..04a2654 --- /dev/null +++ b/src/lib/form.ts @@ -0,0 +1,54 @@ +import { h } from 'lib/h' +import * as Layout from 'lib/layout' +import * as Button from 'lib/button' + +interface InputParams { + label: string, + attrs: object, +} + +export function input({ label, attrs }: InputParams): Element { + return h('label', + { className: 'g-Form__Field' }, + label, + h('input', attrs), + ) +} + +interface ColorInputParams { + colors: string[], + label: string, + initValue: string, + onInput: (value: string) => void, +} + +export function colorInput({ colors, label, initValue, onInput }: ColorInputParams): Element { + const input = h('input', + { value: initValue, + type: 'color', + oninput: (e: Event) => { + if (e.target !== null) { + onInput((e.target as HTMLInputElement).value) + } + } + } + ) as HTMLInputElement + return h('label', + { className: 'g-Form__Field' }, + label, + Layout.line( + {}, + input, + ...colors.map(color => + Button.raw({ className: 'g-Form__Color', + style: `background-color: ${color}`, + type: 'button', + onclick: () => { + input.value = color + onInput(color) + } + }) + ) + ) + ) +} diff --git a/src/lib/h.ts b/src/lib/h.ts new file mode 100644 index 0000000..7e93311 --- /dev/null +++ b/src/lib/h.ts @@ -0,0 +1,31 @@ +type Child = Element | Text | string | number + +export type Children = Child[] + +export function h(tagName: string, attrs: object, ...children: Children): Element { + let elem = document.createElement(tagName) + elem = Object.assign(elem, attrs) + appendChildren(elem, ...children) + return elem +} + +export function s(tagName: string, attrs: object, ...children: Children): Element { + let elem = document.createElementNS('http://www.w3.org/2000/svg', tagName) + Object.entries(attrs).forEach(([key, value]) => elem.setAttribute(key, value)) + appendChildren(elem, ...children) + return elem +} + +function appendChildren(elem: Element, ...children: Children) { + for (const child of children) { + if (typeof child === 'number') + elem.append(child.toString()) + else + elem.append(child) + } +} + +export function concatClassName(attrs: any, className: string): object { + const existingClassName = 'className' in attrs ? attrs['className'] : undefined + return { ...attrs, className: `${className} ${existingClassName}` } +} diff --git a/src/lib/icons.ts b/src/lib/icons.ts new file mode 100644 index 0000000..8db4e17 --- /dev/null +++ b/src/lib/icons.ts @@ -0,0 +1,66 @@ +import { h, s } from 'lib/h' + +export function get(key: string, attrs: object = {}): Element { + const elem = fromKey(key) + if (elem !== undefined) { + Object.entries(attrs).forEach(([key, value]) => { + elem.setAttribute(key, value) + }) + return elem + } else { + return h('span', {}) + } +} + +// https://yqnn.github.io/svg-path-editor/ +function fromKey(key: string): Element | undefined { + if (key == 'house') { + return s('svg', + { viewBox: '0 0 10 10' }, + s('g', { 'stroke': 'none' }, + s('path', { d: 'M0 4V5H1.5V10H4V7C4.4 6.5 5.6 6.5 6 7V10H8.5V5H10V4L5 0Z' }) + ) + ) + } else if (key == 'music') { + return s('svg', + { viewBox: '0 0 10 10' }, + s('g', { 'stroke': 'none' }, + s('ellipse', { cx: '2', cy: '8.5', rx: '2', ry: '1.5' }), + s('ellipse', { cx: '8', cy: '7', rx: '2', ry: '1.5' }), + s('path', { d: 'M2.5 8.5 H4 V4.5 L8.5 3 V7 H10 V0 L2.5 2.5 Z' }), + ) + ) + } else if (key == 'shopping-cart') { + return s('svg', + { viewBox: '0 0 10 10' }, + s('circle', { cx: '3.3', cy: '8.5', r: '0.8' }), + s('circle', { cx: '7.3', cy: '8.5', r: '0.8' }), + s('path', { d: 'M.5.6C1.3.6 1.8.7 2.1 1L2.3 6H8.5', fill: 'transparent' }), + s('path', { d: 'M2.3 1.9H9.4L8.6 4H2.4' }), + ) + } else if (key == 'medical') { + return s('svg', + { viewBox: '0 0 10 10' }, + s('path', { d: 'M5 1V9M1 5H9', style: 'stroke-width: 3' }), + ) + } else if (key == 'envelope') { + return s('svg', + { viewBox: '0 0 10 10' }, + s('path', { d: 'M.5 2.5H9.5V7.5H.5ZM.5 3.4 3.5 5Q5 5.8 6.6 5L9.5 3.4', style: 'fill: transparent' }), + ) + } +} + +// Good to add: +// - loisir / cinéma / piscine +// - école +// - gare +// - bus +export function keys(): string[] { + return [ 'house', + 'music', + 'shopping-cart', + 'medical', + 'envelope', + ] +} diff --git a/src/lib/layout.ts b/src/lib/layout.ts new file mode 100644 index 0000000..1e38bfd --- /dev/null +++ b/src/lib/layout.ts @@ -0,0 +1,15 @@ +import { h, Children, concatClassName } from 'lib/h' + +export function section(attrs: object, ...children: Children): Element { + return h('div', + concatClassName(attrs, 'g-Layout__Section'), + ...children + ) +} + +export function line(attrs: object, ...children: Children): Element { + return h('div', + concatClassName(attrs, 'g-Layout__Line'), + ...children + ) +} diff --git a/src/lib/modal.ts b/src/lib/modal.ts new file mode 100644 index 0000000..8454e1c --- /dev/null +++ b/src/lib/modal.ts @@ -0,0 +1,28 @@ +import { h } from 'lib/h' +import * as Button from 'lib/button' + +export function show(content: Element) { + document.body.appendChild(h('div', + { id: 'g-Modal' }, + h('div', + { className: 'g-Modal__Curtain', + onclick: () => hide() + } + ), + h('div', + { className: 'g-Modal__Window' }, + Button.raw( + { className: 'g-Modal__Close', + onclick: () => hide() + }, + 'x' + ), + content + ) + )) +} + +export function hide() { + const modal = document.querySelector('#g-Modal') + modal && document.body.removeChild(modal) +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..36b1143 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,3 @@ +import * as Map from 'map' + +document.body.appendChild(Map.view()) diff --git a/src/map.ts b/src/map.ts new file mode 100644 index 0000000..04a1351 --- /dev/null +++ b/src/map.ts @@ -0,0 +1,131 @@ +import { h } from 'lib/h' +import * as Button from 'lib/button' +import * as ContextMenu from 'lib/contextMenu' +import * as Layout from 'lib/layout' +import * as Modal from 'lib/modal' +import * as Marker from 'marker' +import * as MarkerForm from 'markerForm' +import * as State from 'state' +import * as Serialization from 'serialization' +const L = window.L + +export function view() { + // Wait for elements to be on page before installing map + window.setTimeout(installMap, 0) + + return layout() +} + +const mapId: string = 'g-Map__Content' + +function layout(): Element { + return h('div', + { className: 'g-Layout__Page' }, + h('div', + { className: 'g-Layout__Header' }, + h('a', + { className: 'g-Layout__Home', + href: '#' + }, + 'Map' + ) + ) + , h('div', + { className: 'g-Map' }, + h('div', { id: mapId}) + ) + ) +} + +function installMap(): object { + + const map = L.map(mapId, { + center: [51.505, -0.09], + zoom: 2, + attributionControl: false + }) + + map.addLayer(L.tileLayer('http://{s}.tile.osm.org/{z}/{x}/{y}.png')) + + const mapMarkers = L.featureGroup() + map.addLayer(mapMarkers) + + map.addEventListener('contextmenu', e => { + ContextMenu.show( + e.originalEvent, + [ { label: 'Add a marker', + action: () => { + const pos = e.latlng + const lastMarker = State.last() + Modal.show(MarkerForm.view({ + onValidate: (color: string, icon: string, name: string, radius: number) => { + const id = State.add({ pos, color, icon, name, radius }) + Marker.add({ + id, + pos, + color, + icon, + name, + radius, + addToMap: marker => mapMarkers.addLayer(marker), + removeFromMap: marker => mapMarkers.removeLayer(marker) + }) + Modal.hide() + }, + onCancel: () => Modal.hide(), + color: lastMarker ? lastMarker.color : '#3F92CF', + icon: lastMarker ? lastMarker.icon : '', + name: '', + radius: 0, + })) + } + } + ] + ) + }) + + // Init from hash + const hash = window.location.hash.substr(1) + const state = Serialization.decode(hash) + State.reset(state) + addMarkers({ map, mapMarkers, state, isInit: true }) + + // Reload from hash + window.addEventListener('popstate', _ => reloadFromHash(map, mapMarkers)) + + return map +} + +export function reloadFromHash(map: L.Map, mapMarkers: L.FeatureGroup) { + const state = Serialization.decode(window.location.hash.substr(1)) + mapMarkers.clearLayers() + addMarkers({ map, mapMarkers, state, isInit: false }) +} + +interface AddMarkersOptions { + map: L.Map, + mapMarkers: L.FeatureGroup, + state: State.State, + isInit: boolean, +} + +function addMarkers({ map, mapMarkers, state, isInit }: AddMarkersOptions) { + state.forEach((marker, id) => { + const { pos, color, icon, name, radius } = marker + Marker.add({ + id, + pos, + color, + icon, + name, + radius, + addToMap: marker => mapMarkers.addLayer(marker), + removeFromMap: marker => mapMarkers.removeLayer(marker) + }) + }) + + // Focus + if (state.length > 0 && (isInit || !map.getBounds().contains(mapMarkers.getBounds()))) { + map.fitBounds(mapMarkers.getBounds(), { padding: [ 50, 50 ] }) + } +} diff --git a/src/marker.ts b/src/marker.ts new file mode 100644 index 0000000..9f59497 --- /dev/null +++ b/src/marker.ts @@ -0,0 +1,171 @@ +import { h, s } from 'lib/h' +import * as Color from 'lib/color' +import * as ContextMenu from 'lib/contextMenu' +import * as MarkerForm from 'markerForm' +import * as Icons from 'lib/icons' +import * as Modal from 'lib/modal' +import * as State from 'state' +const L = window.L + +interface CreateParams { + id: State.Index, + pos: L.Pos, + color: string, + icon: string, + name: string, + radius: number, + addToMap: (layer: L.Layer | L.FeatureGroup) => void, + removeFromMap: (layer: L.Layer | L.FeatureGroup) => void, +} + +export function add({ id, pos, color, icon, name, radius, addToMap, removeFromMap }: CreateParams) { + const marker = L.marker(pos, { + draggable: true, + autoPan: true, + icon: divIcon({ icon, color, name }), + }) + + const circle = + radius !== 0 + ? L.circle(pos, { radius, color, fillColor: color }) + : undefined + + const layer = + circle !== undefined + ? L.featureGroup([ marker, circle ]) + : L.featureGroup([ marker ]) + + const onUpdate = () => + Modal.show(MarkerForm.view({ + onValidate: (color: string, icon: string, name: string, radius: number) => { + removeFromMap(layer) + add({ id, pos, color, icon, name, radius, addToMap, removeFromMap }) + State.update(id, { pos, color, icon, name, radius }) + Modal.hide() + }, + onCancel: () => Modal.hide(), + color, + icon, + name, + radius, + })) + + marker.addEventListener('contextmenu', e => { + ContextMenu.show( + e.originalEvent, + [ { label: 'Modify', + action: onUpdate, + } + , { label: 'Remove', + action: () => { + removeFromMap(layer) + State.remove(id) + } + } + ] + ) + }) + + marker.addEventListener('drag', e => { + circle && circle.setLatLng(marker.getLatLng()) + }) + + marker.addEventListener('dragend', e => { + const pos = marker.getLatLng() + removeFromMap(layer) + add({ id, pos, color, icon, name, radius, addToMap, removeFromMap }) + State.update(id, { pos, color, icon, name, radius }) + }) + + marker.addEventListener('dblclick', onUpdate) + + addToMap(layer) +} + +interface CreateIconParams { + icon: string, + color: string, + name: string, +} + +function divIcon({ icon, color, name }: CreateIconParams): L.Icon { + const c = Color.parse(color) + const crBlack = Color.contrastRatio({ red: 0, green: 0, blue: 0 }, c) + const crWhite = Color.contrastRatio({ red: 255, green: 255, blue: 255 }, c) + const textCol = crBlack > crWhite ? 'black' : 'white' + const width = 10 + const height = 15 + const stroke = 'black' + const strokeWidth = 0.6 + // Triangle + const t = [ + { x: width * 0.15, y: 7.46 }, + { x: width / 2, y: height }, + { x: width * 0.85, y: 7.46 } + ] + return L.divIcon( + { className: '' + , popupAnchor: [ 0, -34 ] + , html: + h('div', + { className: 'g-Marker' }, + s('svg', + { viewBox: `0 0 ${width} ${height}`, + class: 'g-Marker__Base' + }, + s('circle', + { cx: width / 2, + cy: width / 2, + r: (width - 2 * strokeWidth) / 2, + stroke, + 'stroke-width': strokeWidth, + fill: color + } + ), + s('polygon', + { points: `${t[0].x},${t[0].y} ${t[1].x},${t[1].y} ${t[2].x},${t[2].y}`, + fill: color + } + ), + s('line', + { x1: t[0].x, + y1: t[0].y, + x2: t[1].x, + y2: t[1].y, + stroke, + 'stroke-width': strokeWidth + } + ), + s('line', + { x1: t[1].x, + y1: t[1].y, + x2: t[2].x, + y2: t[2].y, + stroke, + 'stroke-width': strokeWidth + } + ), + ), + Icons.get( + icon, + { class: 'g-Marker__Icon' + , style: `fill: ${textCol}; stroke: ${textCol}` + } + ), + h('div', + { className: 'g-Marker__Title', + style: `color: black; text-shadow: ${textShadow('white', 1, 1)}` + }, + name + ) + ) + } + ) +} + +function textShadow(color: string, w: number, blurr: number): string { + return [[-w, -w], [-w, 0], [-w, w], [0, -w], [0, w], [w, -w], [w, 0], [w, w]] + .map(xs => `${color} ${xs[0]}px ${xs[1]}px ${blurr}px`) + .join(', ') +} + diff --git a/src/markerForm.ts b/src/markerForm.ts new file mode 100644 index 0000000..54670ae --- /dev/null +++ b/src/markerForm.ts @@ -0,0 +1,116 @@ +import { h } from 'lib/h' +import * as AutoComplete from 'lib/autoComplete' +import * as Button from 'lib/button' +import * as Dom from 'lib/dom' +import * as Form from 'lib/form' +import * as Icons from 'lib/icons' +import * as Layout from 'lib/layout' +import * as State from 'state' + +interface FormParams { + onValidate: (color: string, icon: string, name: string, radius: number) => void, + onCancel: () => void, + color: string, + icon: string, + name: string, + radius: number, +} + +export function view({ onValidate, onCancel, color, icon, name, radius }: FormParams): Element { + var radiusStr = radius.toString() + const onSubmit = () => onValidate(color, icon, name, parseInt(radiusStr) || 0) + const domIcon = h('div', + { className: 'g-MarkerForm__Icon' }, + Icons.get(icon, { fill: 'black', stroke: 'black' }) + ) + return h('div', + {}, + Layout.section( + {}, + h('form', + { className: 'g-MarkerForm', + onsubmit: (e: Event) => { + e.preventDefault() + onSubmit() + } + }, + Layout.section( + {}, + Form.input({ + label: 'Name', + attrs: { + oninput: (e: Event) => { + if (e.target !== null) { + name = (e.target as HTMLInputElement).value + } + }, + onblur: (e: Event) => { + if (e.target !== null) { + name = (e.target as HTMLInputElement).value.trim() + } + }, + value: name + } + }), + Form.colorInput({ + colors: State.colors(), + label: 'Color', + initValue: color, + onInput: newColor => color = newColor + }), + h('div', + { className: 'g-Form__Field' }, + h('div', + { className: 'g-Form__Label' }, + h('label', { for: 'g-MarkerForm__IconInput' }, 'Icon') + ), + Layout.line( + { className: 'g-MarkerForm__AutoCompleteAndIcon' }, + AutoComplete.create( + { value: icon, + className: 'g-MarkerForm__AutoComplete' + }, + 'g-MarkerForm__IconInput', + Icons.keys().sort(), + iconKey => h('div', + { className: 'g-MarkerForm__IconEntry' }, + h('div', { className: 'g-MarkerForm__IconElem' }, Icons.get(iconKey, { fill: 'black', stroke: 'black' }) ), + iconKey + ), + newIcon => { + icon = newIcon + Dom.replaceChildren(domIcon, Icons.get(icon, { fill: 'black', stroke: 'black' })) + }), + domIcon + ) + ), + Form.input({ + label: 'Radius (m)', + attrs: { oninput: (e: Event) => { + if (e.target !== null) { + radiusStr = (e.target as HTMLInputElement).value + } + }, + value: radiusStr, + type: 'number', + } + }) + ), + ), + Layout.line( + {}, + Button.action({ onclick: () => onSubmit() }, 'Save'), + Button.cancel( + { onclick: () => onCancel(), + type: 'button' + }, + 'Cancel' + ) + ) + ) + ) +} + +function restrictCharacters(str: string, chars: string): string { + return str.split('').filter(c => chars.indexOf(c) != -1).join('') +} diff --git a/src/serialization.ts b/src/serialization.ts new file mode 100644 index 0000000..4289b36 --- /dev/null +++ b/src/serialization.ts @@ -0,0 +1,44 @@ +import * as Base from 'lib/base' +import * as State from 'state' +import * as Utils from 'serialization/utils' +import * as V0 from 'serialization/v0' + +// Encoding + +const lastVersion = 0 // max is 62 + +export function encode(s: State.State): string { + if (s.length == 0) { + return '' + } else { + const version = Base.encode(BigInt(lastVersion), Base.b62) + const xs = V0.encode(s).map(binaryToBase62).join('-') + return `${version}${xs}` + } +} + +function binaryToBase62(str: string): string { + // Prepend 1 so that we don’t loose leading 0s + return Base.encode(Base.decode('1' + str, Base.b2), Base.b62) +} + +// Decoding + +export function decode(encoded: string): State.State { + if (encoded == '') { + return [] + } else { + const version = Number(Base.decode(encoded.slice(0, 1), Base.b62)) + if (version == 0) return V0.decode(encoded.slice(1).split('-').map(base62ToBinary)) + else { + console.error(`Unknown decoder version ${version} in order to decode state.`) + return [] + } + } +} + +function base62ToBinary(str: string): string { + // Remove prepended 1 + return Base.encode(Base.decode(str, Base.b62), Base.b2).slice(1) +} + diff --git a/src/serialization/utils.ts b/src/serialization/utils.ts new file mode 100644 index 0000000..c94f199 --- /dev/null +++ b/src/serialization/utils.ts @@ -0,0 +1,9 @@ +import * as Base from 'lib/base' + +export function encodeNumber(n: bigint, length: number): string { + return Base.encode(n, Base.b2).padStart(length, '0') +} + +export function mod(a: number, b: number): number { + return ((a % b) + b) % b +} diff --git a/src/serialization/v0.ts b/src/serialization/v0.ts new file mode 100644 index 0000000..f90eb66 --- /dev/null +++ b/src/serialization/v0.ts @@ -0,0 +1,122 @@ +import * as Base from 'lib/base' +import * as Icons from 'lib/icons' +import * as State from 'state' +import * as Utils from 'serialization/utils' + +const posPrecision: number = 5 +const latLength: number = Base.encode(BigInt(`180${'0'.repeat(posPrecision)}`), Base.b2).length +const lngLength: number = Base.encode(BigInt(`360${'0'.repeat(posPrecision)}`), Base.b2).length +const colorLength: number = Base.encode(Base.decode('ffffff', Base.b16), Base.b2).length +const iconLength: number = 8 // At most 255 icons +const radiusLength: number = 5 + +// Encoding + +export function encode(s: State.State): string[] { + return s.map(encodeMarker) +} + +function encodeMarker({ pos, name, color, icon, radius }: State.Marker): string { + const lat = encodeLatOrLng(pos.lat, latLength, 180) // [-90; 90] + const lng = encodeLatOrLng(pos.lng, lngLength, 360) // [-180; 180] + return lat + lng + encodeColor(color) + encodeIcon(icon) + encodeRadius(radius) + encodeName(name) +} + +// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/encodeURIComponent +export const uriComponentBase: string[] = + '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ-_.!~*\'()%'.split('') + +function encodeLatOrLng(n: number, length: number, range: number): string { + const [a, b] = Utils.mod(n + range / 2, range).toFixed(posPrecision).split('.') + return Utils.encodeNumber(BigInt(a + b), length) +} + +function encodeColor(color: string): string { + return Utils.encodeNumber(Base.decode(color.slice(1).toLowerCase(), Base.b16), colorLength) +} + +function encodeIcon(icon: string): string { + return icon == '' + ? '0' + : `1${Utils.encodeNumber(BigInt(Icons.keys().indexOf(icon) + 1), iconLength)}` +} + +function encodeRadius(radius: number): string { + if (radius == 0) { + return '0' + } else { + const binary = Base.encode(BigInt(radius), Base.b2) + const binaryLength = Utils.encodeNumber(BigInt(binary.length), radiusLength) + return `1${binaryLength}${binary}` + } +} + +function encodeName(str: string): string { + return str == '' + ? '' + : Base.encode(Base.decode(encodeURIComponent(str), uriComponentBase), Base.b2) +} + +// Decoding + +export function decode(encoded: string[]): State.State { + return encoded.map(binary => { + const [ lat, i1 ] = decodeLatOrLng(binary, 0, latLength, 180) + const [ lng, i2 ] = decodeLatOrLng(binary, i1, lngLength, 360) + const [ color, i3 ] = decodeColor(binary, i2) + const [ icon, i4 ] = decodeIcon(binary, i3) + const [ radius, i5 ] = decodeRadius(binary, i4) + const name = decodeName(binary, i5) + + return { + pos: { lat, lng }, + name, + color, + icon, + radius, + } + }) +} + +function decodeLatOrLng(encoded: string, i: number, length: number, range: number): [number, number] { + const slice = encoded.slice(i, i + length) + const digits = Base.decode(slice, Base.b2).toString() + const latOrLng = parseFloat(`${digits.slice(0, -posPrecision)}.${digits.slice(-posPrecision)}`) - range / 2 + return [ latOrLng, i + length ] +} + +function decodeColor(encoded: string, i: number): [ string, number ] { + const slice = encoded.slice(i, i + colorLength) + const color = `#${Base.encode(Base.decode(slice, Base.b2), Base.b16)}` + return [ color, i + colorLength ] +} + +function decodeIcon(encoded: string, i: number): [ string, number ] { + if (encoded.slice(i, i + 1) == '0') { + return [ '', i + 1 ] + } else { + const slice = encoded.slice(i + 1, i + 1 + iconLength) + const iconIndex = Number(Base.decode(slice, Base.b2)) - 1 + const icon = iconIndex < 0 ? '' : Icons.keys()[iconIndex] + return [ icon, i + 1 + iconLength ] + } +} + +function decodeRadius(encoded: string, i: number): [ number, number ] { + if (encoded.slice(i, i + 1) == '0') { + return [ 0, i + 1 ] + } else { + const binaryLength = encoded.slice(i + 1, i + 1 + radiusLength) + const length = Number(Base.decode(binaryLength, Base.b2)) + const binary = encoded.slice(i + 1 + radiusLength, i + 1 + radiusLength + length) + const radius = Number(Base.decode(binary, Base.b2)) + return [ radius, i + 1 + radiusLength + length ] + } +} + +function decodeName(encoded: string, i: number): string { + const slice = encoded.slice(i) + return slice == '' + ? '' + : decodeURIComponent(Base.encode(Base.decode(slice, Base.b2), uriComponentBase)) +} diff --git a/src/state.ts b/src/state.ts new file mode 100644 index 0000000..634319a --- /dev/null +++ b/src/state.ts @@ -0,0 +1,65 @@ +import * as Serialization from 'serialization' + +const L = window.L + +// State + +var nextIndex: Index = 0 + +export type State = Marker[] +export type Index = number + +var state: State = [] + +export interface Marker { + pos: L.Pos, + name: string, + color: string, + icon: string, + radius: number, +} + +export function reset(s: State) { + state = s + nextIndex = s.length +} + +// CRUD + +export function add(marker: Marker): Index { + const index = nextIndex + state[index] = marker + nextIndex += 1 + pushState() + return index +} + +export function update(index: Index, marker: Marker) { + state[index] = marker + pushState() +} + +export function remove(index: Index) { + delete state[index] + pushState() +} + +// History + +function pushState() { + const encoded = Serialization.encode(Object.values(state)) + history.pushState('', '', `#${encoded}`) +} + +// Inspection + +export function colors() { + return [...new Set(Object.values(state).map(({ color }) => color))] +} + +export function last(): Marker | undefined { + const nonempty = Object.values(state) + return nonempty.length > 0 + ? nonempty.slice(-1)[0] + : undefined +} diff --git a/src/types/leaflet.d.ts b/src/types/leaflet.d.ts new file mode 100644 index 0000000..c1eef16 --- /dev/null +++ b/src/types/leaflet.d.ts @@ -0,0 +1,95 @@ +export as namespace L + +// Map + +export function map(element: string, options?: MapOptions): Map + +export interface MapOptions { + center: number[], + zoom: number, + attributionControl: boolean, +} + +export interface Map { + addLayer: (layer: Layer | FeatureGroup) => void, + removeLayer: (layer: Layer | FeatureGroup) => void, + addEventListener: (name: string, fn: (e: MapEvent) => void) => void, + getBounds: () => LatLngBounds, + fitBounds: (bounds: LatLngBounds, options: { padding: [number, number] } | undefined) => void, +} + +// LatLngBounds + +export interface LatLngBounds { + contains: (otherBounds: LatLngBounds) => boolean, +} + +// Feature group + +export interface FeatureGroup { + clearLayers: () => void, + addLayer: (layer: Layer | FeatureGroup) => void, + removeLayer: (layer: Layer | FeatureGroup) => void, + getBounds: () => LatLngBounds, +} + +export function featureGroup(xs?: Layer[]): L.FeatureGroup + +// Layer + +export interface Layer { + addEventListener: (name: string, fn: (e: MapEvent) => void) => void, + getLatLng: () => Pos, + setLatLng: (pos: Pos) => void, +} + +export function tileLayer(url: string): Layer + +// Marker + +export function marker( + pos: Pos, + options: { + draggable: boolean, + autoPan: boolean, + icon: Icon, + } +): Layer + +// Circle + +export function circle( + pos: Pos, + options: { + radius: number, + color: string, + fillColor: string, + }, +): Layer + +// Icon + +export interface Icon {} + +export function divIcon( + params: { + className: string, + popupAnchor: number[], + html: Element, + } +): Icon + +// Pos + +export interface Pos { + lat: number, + lng: number, +} + +// MapEvent + +interface MapEvent { + originalEvent: MouseEvent, + latlng: {lat: number, lng: number}, +} + -- cgit v1.2.3