From 632eef6424d8dc8d40c2906177892697679e7b85 Mon Sep 17 00:00:00 2001 From: Joris Date: Sat, 19 Apr 2025 12:36:38 +0200 Subject: Add ZIG server --- .gitignore | 6 +- .tmuxinator.yml | 19 + Makefile | 9 - README.md | 28 +- backend/bin/dev-server | 20 + backend/build.zig | 49 + backend/build.zig.zon | 14 + backend/fixtures.sql | 17 + backend/src/lib/nanoid.zig | 287 + backend/src/main.zig | 65 + backend/src/repos/maps_repo.zig | 84 + backend/src/repos/markers_repo.zig | 114 + backend/src/repos/migrations/01.sql | 29 + backend/src/repos/repos.zig | 62 + backend/src/repos/users_repo.zig | 90 + backend/src/services/auth_service.zig | 31 + backend/src/services/common.zig | 30 + backend/src/services/handler.zig | 69 + backend/src/services/maps_service.zig | 39 + backend/src/services/markers_service.zig | 31 + backend/src/services/static.zig | 32 + backend/src/services/users_service.zig | 8 + bin/dev | 22 + bin/dev-server | 16 - flake.lock | 73 +- flake.nix | 63 +- frontend/Makefile | 16 + frontend/bin/compile-sass | 3 + frontend/bin/compile-ts | 6 + frontend/bin/dev-server | 23 + frontend/html/index.html | 11 + frontend/images/icon.png | Bin 0 -> 4525 bytes frontend/styles/colors.sass | 7 + frontend/styles/form.sass | 123 + frontend/styles/leaflet.css | 656 ++ frontend/styles/lib/icons.sass | 15 + frontend/styles/main.sass | 71 + frontend/styles/pages/login.sass | 12 + frontend/styles/pages/map.sass | 84 + frontend/styles/pages/maps.sass | 2 + frontend/styles/ui/layout.sass | 25 + frontend/styles/ui/modal.sass | 57 + frontend/ts/src/lib/color.ts | 36 + frontend/ts/src/lib/form.ts | 175 + frontend/ts/src/lib/icons.ts | 94 + frontend/ts/src/lib/leaflet.d.ts | 93 + frontend/ts/src/lib/leaflet.js | 14421 +++++++++++++++++++++++++++++ frontend/ts/src/lib/loadable.ts | 73 + frontend/ts/src/lib/router.ts | 25 + frontend/ts/src/lib/rx.ts | 781 ++ frontend/ts/src/main.ts | 79 + frontend/ts/src/models/map.ts | 4 + frontend/ts/src/models/marker.ts | 18 + frontend/ts/src/models/user.ts | 4 + frontend/ts/src/pages/layout.ts | 37 + frontend/ts/src/pages/login.ts | 67 + frontend/ts/src/pages/map.ts | 231 + frontend/ts/src/pages/map/footer.ts | 169 + frontend/ts/src/pages/map/marker.ts | 129 + frontend/ts/src/pages/map/markerForm.ts | 172 + frontend/ts/src/pages/maps.ts | 94 + frontend/ts/src/pages/notFound.ts | 5 + frontend/ts/src/request.ts | 63 + frontend/ts/src/route.ts | 59 + frontend/ts/src/ui/layout.ts | 34 + frontend/ts/src/ui/modal.ts | 67 + frontend/ts/tsconfig.json | 12 + public/icon.png | Bin 4525 -> 0 bytes public/index.html | 15 - public/leaflet/leaflet.css | 656 -- public/leaflet/leaflet.js | 6 - public/main.css | 307 - 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 - tsconfig.json | 13 - 93 files changed, 19302 insertions(+), 2258 deletions(-) create mode 100644 .tmuxinator.yml delete mode 100644 Makefile create mode 100755 backend/bin/dev-server create mode 100644 backend/build.zig create mode 100644 backend/build.zig.zon create mode 100644 backend/fixtures.sql create mode 100644 backend/src/lib/nanoid.zig create mode 100644 backend/src/main.zig create mode 100644 backend/src/repos/maps_repo.zig create mode 100644 backend/src/repos/markers_repo.zig create mode 100644 backend/src/repos/migrations/01.sql create mode 100644 backend/src/repos/repos.zig create mode 100644 backend/src/repos/users_repo.zig create mode 100644 backend/src/services/auth_service.zig create mode 100644 backend/src/services/common.zig create mode 100644 backend/src/services/handler.zig create mode 100644 backend/src/services/maps_service.zig create mode 100644 backend/src/services/markers_service.zig create mode 100644 backend/src/services/static.zig create mode 100644 backend/src/services/users_service.zig create mode 100755 bin/dev delete mode 100755 bin/dev-server create mode 100644 frontend/Makefile create mode 100755 frontend/bin/compile-sass create mode 100755 frontend/bin/compile-ts create mode 100755 frontend/bin/dev-server create mode 100644 frontend/html/index.html create mode 100644 frontend/images/icon.png create mode 100644 frontend/styles/colors.sass create mode 100644 frontend/styles/form.sass create mode 100644 frontend/styles/leaflet.css create mode 100644 frontend/styles/lib/icons.sass create mode 100644 frontend/styles/main.sass create mode 100644 frontend/styles/pages/login.sass create mode 100644 frontend/styles/pages/map.sass create mode 100644 frontend/styles/pages/maps.sass create mode 100644 frontend/styles/ui/layout.sass create mode 100644 frontend/styles/ui/modal.sass create mode 100644 frontend/ts/src/lib/color.ts create mode 100644 frontend/ts/src/lib/form.ts create mode 100644 frontend/ts/src/lib/icons.ts create mode 100644 frontend/ts/src/lib/leaflet.d.ts create mode 100644 frontend/ts/src/lib/leaflet.js create mode 100644 frontend/ts/src/lib/loadable.ts create mode 100644 frontend/ts/src/lib/router.ts create mode 100644 frontend/ts/src/lib/rx.ts create mode 100644 frontend/ts/src/main.ts create mode 100644 frontend/ts/src/models/map.ts create mode 100644 frontend/ts/src/models/marker.ts create mode 100644 frontend/ts/src/models/user.ts create mode 100644 frontend/ts/src/pages/layout.ts create mode 100644 frontend/ts/src/pages/login.ts create mode 100644 frontend/ts/src/pages/map.ts create mode 100644 frontend/ts/src/pages/map/footer.ts create mode 100644 frontend/ts/src/pages/map/marker.ts create mode 100644 frontend/ts/src/pages/map/markerForm.ts create mode 100644 frontend/ts/src/pages/maps.ts create mode 100644 frontend/ts/src/pages/notFound.ts create mode 100644 frontend/ts/src/request.ts create mode 100644 frontend/ts/src/route.ts create mode 100644 frontend/ts/src/ui/layout.ts create mode 100644 frontend/ts/src/ui/modal.ts create mode 100644 frontend/ts/tsconfig.json delete mode 100644 public/icon.png delete mode 100644 public/index.html delete mode 100644 public/leaflet/leaflet.css delete mode 100644 public/leaflet/leaflet.js delete mode 100644 public/main.css delete mode 100644 src/lib/autoComplete.ts delete mode 100644 src/lib/base.ts delete mode 100644 src/lib/button.ts delete mode 100644 src/lib/color.ts delete mode 100644 src/lib/contextMenu.ts delete mode 100644 src/lib/dom.ts delete mode 100644 src/lib/form.ts delete mode 100644 src/lib/h.ts delete mode 100644 src/lib/icons.ts delete mode 100644 src/lib/layout.ts delete mode 100644 src/lib/modal.ts delete mode 100644 src/main.ts delete mode 100644 src/map.ts delete mode 100644 src/marker.ts delete mode 100644 src/markerForm.ts delete mode 100644 src/serialization.ts delete mode 100644 src/serialization/utils.ts delete mode 100644 src/serialization/v0.ts delete mode 100644 src/state.ts delete mode 100644 src/types/leaflet.d.ts delete mode 100644 tsconfig.json diff --git a/.gitignore b/.gitignore index b62f3b0..f7b7942 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ -/public/main.js +.zig-cache +zig-out +backend/public +.sass-cache +db.sqlite3* diff --git a/.tmuxinator.yml b/.tmuxinator.yml new file mode 100644 index 0000000..83fe649 --- /dev/null +++ b/.tmuxinator.yml @@ -0,0 +1,19 @@ +name: maps + +windows: + - editor: + layout: main-vertical + panes: + - editor: + - nix develop + - nvim -c :Git -c :on + - backend: + - sleep 1 + - nix develop + - cd backend + - bin/dev-server maps-backend + - frontend: + - sleep 2 + - nix develop + - cd frontend + - bin/dev-server maps-frontend diff --git a/Makefile b/Makefile deleted file mode 100644 index 593752d..0000000 --- a/Makefile +++ /dev/null @@ -1,9 +0,0 @@ -build: - @esbuild \ - --bundle src/main.ts \ - --minify \ - --target=es2017 \ - --outdir=public - -clean: - @rm -f public/main.js diff --git a/README.md b/README.md index 880f174..df7eab6 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,29 @@ -# Map +# Maps -Add markers on a map and save state in URL. +Manage maps of markers. -# Getting started +## Getting started -Enter nix shell: +Get into nix-shell: nix develop -Then start the dev server: +Start the server: - bin/dev-server + bin/dev start -Finally, open your browser at `http://localhost:8000`. +Add fixtures: + + sqlite3 db.sqlite3 < fixtures.sql + +Credentials are: + + - john@mail.com / password + - lisa@mail.com / password + +## Improvements + +- mobile view +- show last updated maps first +- share with public link as readonly (allow to revoke) +- archive instead of delete diff --git a/backend/bin/dev-server b/backend/bin/dev-server new file mode 100755 index 0000000..680e736 --- /dev/null +++ b/backend/bin/dev-server @@ -0,0 +1,20 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Killing watchexec ourselves, it may not be done otherwise. +function finish { + if [ -n "${LIVE_SERVER_PID:-}" ]; then + kill "$LIVE_SERVER_PID" > /dev/null 2>&1 + fi +} + +trap finish EXIT + +watchexec \ + --watch src \ + --clear clear \ + --restart \ + zig build run & +LIVE_SERVER_PID="$!" + +while true; do sleep 1; done diff --git a/backend/build.zig b/backend/build.zig new file mode 100644 index 0000000..3fd7c48 --- /dev/null +++ b/backend/build.zig @@ -0,0 +1,49 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + + const optimize = b.standardOptimizeOption(.{}); + + const exe = b.addExecutable(.{ + .name = "backend", + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + + const httpz = b.dependency("httpz", .{ + .target = target, + .optimize = optimize, + }); + exe.root_module.addImport("httpz", httpz.module("httpz")); + + const zqlite = b.dependency("zqlite", .{ + .target = target, + .optimize = optimize, + }); + exe.linkLibC(); + exe.linkSystemLibrary("sqlite3"); + exe.root_module.addImport("zqlite", zqlite.module("zqlite")); + + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| { + run_cmd.addArgs(args); + } + + const run_step = b.step("run", "Run the app"); + run_step.dependOn(&run_cmd.step); + + const exe_unit_tests = b.addTest(.{ + .root_source_file = b.path("src/main.zig"), + .target = target, + .optimize = optimize, + }); + const run_exe_unit_tests = b.addRunArtifact(exe_unit_tests); + + const test_step = b.step("test", "Run unit tests"); + test_step.dependOn(&run_exe_unit_tests.step); +} diff --git a/backend/build.zig.zon b/backend/build.zig.zon new file mode 100644 index 0000000..f4a0e01 --- /dev/null +++ b/backend/build.zig.zon @@ -0,0 +1,14 @@ +.{ .name = .backend, .version = "0.0.0", .fingerprint = 0x8fe5c971579344fd, .minimum_zig_version = "0.14.0-dev.3451+d8d2aa9af", .dependencies = .{ + .httpz = .{ + .url = "git+https://github.com/karlseguin/http.zig?ref=master#458281c7d84066d4186bd642c03260ed542e22e5", + .hash = "httpz-0.0.0-PNVzrJSuBgCAq2ufQxEcgDV4npCsGEJWH7qMi5PL2669", + }, + .zqlite = .{ + .url = "git+https://github.com/karlseguin/zqlite.zig?ref=master#d146898482a4c4bdcba487496f67a24afa12894d", + .hash = "zqlite-0.0.0-RWLaYxp7lQBh-o0STqzS1uZZb0tLTCGBDgzpG_dGjd2J", + }, +}, .paths = .{ + "build.zig", + "build.zig.zon", + "src", +} } diff --git a/backend/fixtures.sql b/backend/fixtures.sql new file mode 100644 index 0000000..0a2dedb --- /dev/null +++ b/backend/fixtures.sql @@ -0,0 +1,17 @@ +INSERT INTO + users(email, name, password_hash) +VALUES + ('john@mail.com', 'John', '$2b$10$Qy8lqrTqHdzwLZwsqvO09eMwehA.vti.AGwPVj/pZYL94Ni6zozT2'), + ('lisa@mail.com', 'Lisa', '$2b$10$Qy8lqrTqHdzwLZwsqvO09eMwehA.vti.AGwPVj/pZYL94Ni6zozT2'); + +INSERT INTO + maps(id, name) +VALUES + ('SZj90bEkbSmjEDv7l6BD-', 'France'); + +INSERT INTO + markers(id, map_id, name, lat, lng, color) +VALUES + ('k3OcuT_7PwNyIxW7etb4Z', 'SZj90bEkbSmjEDv7l6BD-', 'Paris', 48.857487002645485, 2.3510742187500004, '#3584e4'), + ('ilaX_3aVFfgFTc-sRO4F7', 'SZj90bEkbSmjEDv7l6BD-', 'Bordeaux', 44.855868807357275, -0.5932617187500001, '#3584e4'), + ('Ciob_GpDrhlFmFnV2KkMh', 'SZj90bEkbSmjEDv7l6BD-', 'Marseille', 43.30119623257966, 5.394287109375, '#3584e4'); diff --git a/backend/src/lib/nanoid.zig b/backend/src/lib/nanoid.zig new file mode 100644 index 0000000..64ab9bd --- /dev/null +++ b/backend/src/lib/nanoid.zig @@ -0,0 +1,287 @@ +// Copied from: +// https://github.com/SasLuca/zig-nanoid/blob/91e0a9a8890984f3dcdd98c99002a05a83d0ee89/src/nanoid.zig +// As it doesn’t support ZON build yet. + +const std = @import("std"); + +/// A collection of useful alphabets that can be used to generate ids. +pub const alphabets = struct { + /// Numbers from 0 to 9. + pub const numbers = "0123456789"; + + /// English hexadecimal with lowercase characters. + pub const hexadecimal_lowercase = numbers ++ "abcdef"; + + /// English hexadecimal with uppercase characters. + pub const hexadecimal_uppercase = numbers ++ "ABCDEF"; + + /// Lowercase English letters. + pub const lowercase = "abcdefghijklmnopqrstuvwxyz"; + + /// Uppercase English letters. + pub const uppercase = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; + + /// Numbers and english letters without lookalikes: 1, l, I, 0, O, o, u, v, 5, S, s, 2, Z. + pub const no_look_alikes = "346789ABCDEFGHJKLMNPQRTUVWXYabcdefghijkmnpqrtwxyz"; + + /// Same as nolookalikes but with removed vowels and following letters: 3, 4, x, X, V. + /// This list should protect you from accidentally getting obscene words in generated strings. + pub const no_look_alikes_safe = "6789BCDFGHJKLMNPQRTWbcdfghjkmnpqrtwz"; + + /// Combination of all the lowercase, uppercase characters and numbers from 0 to 9. + /// Does not include any symbols or special characters. + pub const alphanumeric = numbers ++ lowercase ++ uppercase; + + /// URL friendly characters used by the default generate procedure. + pub const default = "_-" ++ alphanumeric; + + /// An array of all the alphabets. + pub const all = [_][]const u8{ + numbers, + hexadecimal_lowercase, + hexadecimal_uppercase, + lowercase, + uppercase, + no_look_alikes, + no_look_alikes_safe, + alphanumeric, + default, + }; +}; + +/// The default length for nanoids. +pub const default_id_len = 21; + +/// The mask for the default alphabet length. +pub const default_mask = computeMask(alphabets.default.len); + +/// This should be enough memory for an rng step buffer when generating an id of default length regardless of alphabet length. +/// It can be used for allocating your rng step buffer if you know the length of your id is `<= default_id_len`. +pub const rng_step_buffer_len_sufficient_for_default_length_ids = computeSufficientRngStepBufferLengthFor(default_id_len); + +/// The maximum length of the alphabet accepted by the nanoid algorithm. +pub const max_alphabet_len: u8 = std.math.maxInt(u8); + +/// Computes the mask necessary for the nanoid algorithm given an alphabet length. +/// The mask is used to transform a random byte into an index into an array of length `alphabet_len`. +/// +/// Parameters: +/// - `alphabet_len`: the length of the alphabet used. The alphabet length must be in the range `(0, max_alphabet_len]`. +pub fn computeMask(alphabet_len: u8) u8 { + std.debug.assert(alphabet_len > 0); + + const clz: u5 = @clz(@as(u31, (alphabet_len - 1) | 1)); + const mask = (@as(u32, 2) << (31 - clz)) - 1; + const result: u8 = @truncate(mask); + return result; +} + +/// Computes the length necessary for a buffer which can hold the random byte in a step of a the nanoid generation algorithm given a +/// certain alphabet length. +/// +/// Parameters: +/// - `id_len`: the length of the id you will generate. Can be any value. +/// +/// - `alphabet_len`: the length of the alphabet used. The alphabet length must be in the range `(0, max_alphabet_len]`. +pub fn computeRngStepBufferLength(id_len: usize, alphabet_len: u8) usize { + // @Note: + // Original dev notes regarding this algorithm. + // Source: https://github.com/ai/nanoid/blob/0454333dee4612d2c2e163d271af6cc3ce1e5aa4/index.js#L45 + // + // "Next, a step determines how many random bytes to generate. + // The number of random bytes gets decided upon the ID length, mask, + // alphabet length, and magic number 1.6 (using 1.6 peaks at performance + // according to benchmarks)." + + const id_len_f: f64 = @as(f64, @floatFromInt(id_len)); + const mask_f: f64 = @as(f64, @floatFromInt(computeMask(alphabet_len))); + const alphabet_len_f: f64 = @as(f64, @floatFromInt(alphabet_len)); + const step_buffer_len: f64 = @ceil(1.6 * mask_f * id_len_f / alphabet_len_f); + const result = @as(usize, @intFromFloat(step_buffer_len)); + + return result; +} + +/// This function computes the biggest possible rng step buffer length necessary +/// to compute an id with a max length of `max_id_len` regardless of the alphabet length. +/// +/// Parameters: +/// - `max_id_len`: The biggest id length for which the step buffer length needs to be sufficient. +pub fn computeSufficientRngStepBufferLengthFor(max_id_len: usize) usize { + @setEvalBranchQuota(2500); + var max_step_buffer_len: usize = 0; + var i: u9 = 1; + while (i <= max_alphabet_len) : (i += 1) { + const alphabet_len: u8 = @truncate(i); + const step_buffer_len = computeRngStepBufferLength(max_id_len, alphabet_len); + + if (step_buffer_len > max_step_buffer_len) { + max_step_buffer_len = step_buffer_len; + } + } + + return max_step_buffer_len; +} + +/// Generates a nanoid inside `result_buffer` and returns it back to the caller. +/// +/// Parameters: +/// - `rng`: a random number generator. +/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns. +/// See `Regarding RNGs` in `readme.md` for more information. +/// +/// - `alphabet`: an array of the bytes that will be used in the id, its length must be in the range `(0, max_alphabet_len]`. +/// Consider the options from `nanoid.alphabets`. +/// +/// - `result_buffer`: is an output buffer that will be filled *completely* with random bytes from `alphabet`, thus generating an id of +/// length `result_buffer.len`. This buffer will be returned at the end of the function. +/// +/// - `step_buffer`: The buffer will be filled with random bytes using `rng.bytes()`. +/// Must be at least `computeRngStepBufferLength(computeMask(@truncate(alphabet.len)), result_buffer.len, alphabet.len)` bytes. +pub fn generateEx(rng: std.Random, alphabet: []const u8, result_buffer: []u8, step_buffer: []u8) []u8 { + std.debug.assert(alphabet.len > 0 and alphabet.len <= max_alphabet_len); + + const alphabet_len: u8 = @truncate(alphabet.len); + const mask = computeMask(alphabet_len); + const necessary_step_buffer_len = computeRngStepBufferLength(result_buffer.len, alphabet_len); + const actual_step_buffer = step_buffer[0..necessary_step_buffer_len]; + + var result_iter: usize = 0; + while (true) { + rng.bytes(actual_step_buffer); + + for (actual_step_buffer) |it| { + const alphabet_index = it & mask; + + if (alphabet_index >= alphabet_len) { + continue; + } + + result_buffer[result_iter] = alphabet[alphabet_index]; + + if (result_iter == result_buffer.len - 1) { + return result_buffer; + } else { + result_iter += 1; + } + } + } +} + +/// Generates a nanoid inside `result_buffer` and returns it back to the caller. +/// +/// This function will use `rng.int` instead of `rng.bytes` thus avoiding the need for a step buffer. +/// Depending on your choice of rng this can be useful, since you avoid the need for a step buffer, +/// but repeated calls to `rng.int` might be slower than a single call `rng.bytes`. +/// +/// Parameters: +/// - `rng`: a random number generator. +/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns. +/// See `Regarding RNGs` in `readme.md` for more information. +/// +/// - `alphabet`: an array of the bytes that will be used in the id, its length must be in the range `(0, max_alphabet_len]`. +/// Consider the options from `nanoid.alphabets`. +/// +/// - `result_buffer` is an output buffer that will be filled *completely* with random bytes from `alphabet`, thus generating an id of +/// length `result_buffer.len`. This buffer will be returned at the end of the function. +pub fn generateExWithIterativeRng(rng: std.Random, alphabet: []const u8, result_buffer: []u8) []u8 { + std.debug.assert(result_buffer.len > 0); + std.debug.assert(alphabet.len > 0 and alphabet.len <= max_alphabet_len); + + const alphabet_len: u8 = @truncate(alphabet.len); + const mask = computeMask(alphabet_len); + + var result_iter: usize = 0; + while (true) { + const random_byte = rng.int(u8); + + const alphabet_index = random_byte & mask; + + if (alphabet_index >= alphabet_len) { + continue; + } + + result_buffer[result_iter] = alphabet[alphabet_index]; + + if (result_iter == result_buffer.len - 1) { + return result_buffer; + } else { + result_iter += 1; + } + } + + return result_buffer; +} + +/// Generates a nanoid using the provided alphabet. +/// +/// Parameters: +/// +/// - `rng`: a random number generator. +/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns. +/// See `Regarding RNGs` in `README.md` for more information. +/// +/// - `alphabet`: an array of the bytes that will be used in the id, its length must be in the range `(0, max_alphabet_len]`. +pub fn generateWithAlphabet(rng: std.Random, alphabet: []const u8) [default_id_len]u8 { + var nanoid: [default_id_len]u8 = undefined; + var step_buffer: [rng_step_buffer_len_sufficient_for_default_length_ids]u8 = undefined; + _ = generateEx(rng, alphabet, &nanoid, &step_buffer); + return nanoid; +} + +/// Generates a nanoid using the default alphabet. +/// +/// Parameters: +/// +/// - `rng`: a random number generator. +/// Provide a secure one such as `std.Random.DefaultCsprng` and seed it properly if you have security concerns. +/// See `Regarding RNGs` in `README.md` for more information. +pub fn generate(rng: std.Random) [default_id_len]u8 { + const result = generateWithAlphabet(rng, alphabets.default); + return result; +} + +/// Non public utility functions used mostly in unit tests. +const internal_utils = struct { + fn makeDefaultCsprng() std.Random.DefaultCsprng { + // Generate seed + var seed: [std.Random.DefaultCsprng.secret_seed_length]u8 = undefined; + std.crypto.random.bytes(&seed); + + // Initialize the rng and allocator + const rng = std.Random.DefaultCsprng.init(seed); + return rng; + } + + fn makeDefaultPrngWithConstantSeed() std.Random.DefaultPrng { + const rng = std.Random.DefaultPrng.init(0); + return rng; + } + + fn makeDefaultCsprngWithConstantSeed() std.Random.DefaultCsprng { + // Generate seed + const seed: [std.Random.DefaultCsprng.secret_seed_length]u8 = undefined; + for (seed) |*it| it.* = 'a'; + + // Initialize the rng and allocator + const rng = std.Random.DefaultCsprng.init(seed); + return rng; + } + + /// Taken from https://github.com/codeyu/nanoid-net/blob/445f4d363e0079e151ea414dab1a9f9961679e7e/test/Nanoid.Test/NanoidTest.cs#L145 + fn toBeCloseTo(actual: f64, expected: f64, precision: f64) bool { + const pass = @abs(expected - actual) < std.math.pow(f64, 10, -precision) / 2; + return pass; + } + + /// Checks if all elements in `array` are present in `includedIn`. + fn allIn(comptime T: type, array: []const T, includedIn: []const T) bool { + for (array) |it| { + if (std.mem.indexOfScalar(u8, includedIn, it) == null) { + return false; + } + } + + return true; + } +}; diff --git a/backend/src/main.zig b/backend/src/main.zig new file mode 100644 index 0000000..5363392 --- /dev/null +++ b/backend/src/main.zig @@ -0,0 +1,65 @@ +const Allocator = std.mem.Allocator; +const httpz = @import("httpz"); +const std = @import("std"); + +const repos = @import("repos/repos.zig"); + +const static = @import("services/static.zig"); +const handler = @import("services/handler.zig"); +const auth_service = @import("services/auth_service.zig"); +const users_service = @import("services/users_service.zig"); +const maps_service = @import("services/maps_service.zig"); +const markers_service = @import("services/markers_service.zig"); + +pub fn main() !void { + + // ENV + const env = std.posix.getenv; + const port = try std.fmt.parseInt(u16, env("PORT") orelse "3000", 10); + const db_path = env("DB_PATH") orelse "db.sqlite3"; + const secure_tokens = if (std.mem.eql(u8, env("SECURE_TOKENS") orelse "false", "true")) true else false; + + // Allocator + var gpa = std.heap.GeneralPurposeAllocator(.{}){}; + const allocator = gpa.allocator(); + defer _ = gpa.deinit(); + + // DB connection + var conn = try repos.init(allocator, db_path); + defer conn.close(); + + var h = handler.Handler{ .conn = conn, .secure_tokens = secure_tokens }; + var server = try httpz.Server(*handler.Handler).init(allocator, .{ + .port = port, + }, &h); + defer server.deinit(); + defer server.stop(); + + var router = try server.router(.{}); + + const public_route = &handler.RouteData{ .is_public = true }; + + // Static files + router.get("/public/main.css", static.main_css, .{ .data = public_route }); + router.get("/public/main.js", static.main_js, .{ .data = public_route }); + router.get("/public/icon.png", static.icon_png, .{ .data = public_route }); + + // API + router.post("/api/login", auth_service.login, .{ .data = public_route }); + router.post("/api/logout", auth_service.logout, .{}); + router.get("/api/user", users_service.get_user, .{}); + // Maps + router.get("/api/maps", maps_service.list, .{}); + router.get("/api/maps/:id", maps_service.get, .{}); + router.post("/api/maps", maps_service.create, .{}); + router.put("/api/maps/:id", maps_service.update, .{}); + router.delete("/api/maps/:id", maps_service.delete, .{}); + // Markers + router.get("/api/markers", markers_service.list_by_map, .{}); + router.post("/api/markers", markers_service.create, .{}); + router.put("/api/markers/:id", markers_service.update, .{}); + router.delete("/api/markers/:id", markers_service.delete, .{}); + + std.debug.print("listening http://localhost:{d}/\n", .{port}); + try server.listen(); +} diff --git a/backend/src/repos/maps_repo.zig b/backend/src/repos/maps_repo.zig new file mode 100644 index 0000000..8031827 --- /dev/null +++ b/backend/src/repos/maps_repo.zig @@ -0,0 +1,84 @@ +const std = @import("std"); +const zqlite = @import("zqlite"); +const nanoid = @import("../lib/nanoid.zig"); + +pub const Map = struct { + id: []const u8, + name: []const u8, +}; + +pub fn get_maps(allocator: std.mem.Allocator, conn: zqlite.Conn) !std.ArrayList(Map) { + const query = + \\SELECT id, name + \\FROM maps + ; + + var rows = try conn.rows(query, .{}); + defer rows.deinit(); + + var list = std.ArrayList(Map).init(allocator); + while (rows.next()) |row| { + try list.append(Map{ + .id = try allocator.dupe(u8, row.text(0)), + .name = try allocator.dupe(u8, row.text(1)), + }); + } + if (rows.err) |err| return err; + + return list; +} + +pub fn get_map(allocator: std.mem.Allocator, conn: zqlite.Conn, id: []const u8) !?Map { + const query = + \\SELECT id, name + \\FROM maps + \\WHERE id = ? + ; + + if (try conn.row(query, .{id})) |row| { + defer row.deinit(); + return Map{ + .id = try allocator.dupe(u8, row.text(0)), + .name = try allocator.dupe(u8, row.text(1)), + }; + } else { + return null; + } +} + +pub fn create(allocator: std.mem.Allocator, conn: zqlite.Conn, name: []const u8) !Map { + const query = + \\INSERT INTO maps(id, name) + \\VALUES (?, ?) + ; + + const id: []const u8 = &nanoid.generate(std.crypto.random); + try conn.exec(query, .{ id, name }); + return Map{ + .id = try allocator.dupe(u8, id), + .name = name, + }; +} + +pub fn update(conn: zqlite.Conn, id: []const u8, name: []const u8) !Map { + const query = + \\UPDATE maps + \\SET name = ?, updated_at = datetime() + \\WHERE id = ? + ; + + try conn.exec(query, .{ name, id }); + return Map{ + .id = id, + .name = name, + }; +} + +pub fn delete(conn: zqlite.Conn, id: []const u8) !void { + const query = + \\DELETE FROM maps + \\WHERE id = ? + ; + + try conn.exec(query, .{id}); +} diff --git a/backend/src/repos/markers_repo.zig b/backend/src/repos/markers_repo.zig new file mode 100644 index 0000000..232b8b6 --- /dev/null +++ b/backend/src/repos/markers_repo.zig @@ -0,0 +1,114 @@ +const std = @import("std"); +const zqlite = @import("zqlite"); +const nanoid = @import("../lib/nanoid.zig"); + +pub const Marker = struct { + id: []const u8, + lat: f64, + lng: f64, + color: []const u8, + name: []const u8, + description: []const u8, + icon: []const u8, + radius: i64, +}; + +pub fn get_markers(allocator: std.mem.Allocator, conn: zqlite.Conn, map_id: []const u8) !std.ArrayList(Marker) { + const query = + \\SELECT id, lat, lng, color, name, description, icon, radius + \\FROM markers + \\WHERE map_id = ? + ; + + var rows = try conn.rows(query, .{map_id}); + + defer rows.deinit(); + + var list = std.ArrayList(Marker).init(allocator); + while (rows.next()) |row| { + try list.append(Marker{ + .id = try allocator.dupe(u8, row.text(0)), + .lat = row.float(1), + .lng = row.float(2), + .color = try allocator.dupe(u8, row.text(3)), + .name = try allocator.dupe(u8, row.text(4)), + .description = try allocator.dupe(u8, row.text(5)), + .icon = try allocator.dupe(u8, row.text(6)), + .radius = row.int(7), + }); + } + if (rows.err) |err| return err; + + return list; +} + +pub const Payload = struct { + lat: f64, + lng: f64, + color: []const u8, + name: []const u8, + description: []const u8, + icon: []const u8, + radius: i64, +}; + +pub fn create(allocator: std.mem.Allocator, conn: zqlite.Conn, map_id: []const u8, p: Payload) !Marker { + const query = + \\INSERT INTO markers(id, map_id, lat, lng, color, name, description, icon, radius) + \\VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ; + + const id: []const u8 = &nanoid.generate(std.crypto.random); + + try conn.exec(query, .{ id, map_id, p.lat, p.lng, p.color, p.name, p.description, p.icon, p.radius }); + + return Marker{ + .id = try allocator.dupe(u8, id), + .lat = p.lat, + .lng = p.lng, + .color = p.color, + .name = p.name, + .description = p.description, + .icon = p.icon, + .radius = p.radius, + }; +} + +pub fn update(conn: zqlite.Conn, id: []const u8, p: Payload) !Marker { + const query = + \\UPDATE markers + \\SET lat = ?, lng = ?, color = ?, name = ?, description = ?, icon = ?, radius = ?, updated_at = datetime() + \\WHERE id = ? + ; + + try conn.exec(query, .{ p.lat, p.lng, p.color, p.name, p.description, p.icon, p.radius, id }); + + return Marker{ + .id = id, + .lat = p.lat, + .lng = p.lng, + .color = p.color, + .name = p.name, + .description = p.description, + .icon = p.icon, + .radius = p.radius, + }; +} + +pub fn delete(conn: zqlite.Conn, id: []const u8) !void { + const query = + \\DELETE FROM markers + \\WHERE id = ? + ; + + try conn.exec(query, .{id}); +} + +pub fn delete_by_map_id(conn: zqlite.Conn, map_id: []const u8) !void { + const query = + \\DELETE FROM markers + \\WHERE map_id = ? + ; + + try conn.exec(query, .{map_id}); +} diff --git a/backend/src/repos/migrations/01.sql b/backend/src/repos/migrations/01.sql new file mode 100644 index 0000000..cf4131d --- /dev/null +++ b/backend/src/repos/migrations/01.sql @@ -0,0 +1,29 @@ +CREATE TABLE IF NOT EXISTS "users" ( + "email" TEXT PRIMARY KEY, + "created_at" TEXT NOT NULL DEFAULT (datetime()), + "updated_at" TEXT NOT NULL DEFAULT (datetime()), + "password_hash" TEXT NOT NULL, + "name" TEXT NOT NULL, + "login_token" TEXT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS "maps" ( + "id" TEXT PRIMARY KEY, + "created_at" TEXT NOT NULL DEFAULT (datetime()), + "updated_at" TEXT NOT NULL DEFAULT (datetime()), + "name" TEXT NOT NULL +) STRICT; + +CREATE TABLE IF NOT EXISTS "markers" ( + "id" TEXT PRIMARY KEY, + "created_at" TEXT NOT NULL DEFAULT (datetime()), + "updated_at" TEXT NOT NULL DEFAULT (datetime()), + "map_id" TEXT NOT NULL REFERENCES maps(id), + "lat" REAL NOT NULL, + "lng" REAL NOT NULL, + "color" TEXT NOT NULL, + "name" TEXT NULL, + "description" TEXT NULL, + "icon" TEXT NULL, + "radius" INTEGER NULL +) STRICT; diff --git a/backend/src/repos/repos.zig b/backend/src/repos/repos.zig new file mode 100644 index 0000000..860811d --- /dev/null +++ b/backend/src/repos/repos.zig @@ -0,0 +1,62 @@ +const std = @import("std"); +const zqlite = @import("zqlite"); + +pub fn init(allocator: std.mem.Allocator, path: [*:0]const u8) !zqlite.Conn { + const flags = zqlite.OpenFlags.Create | zqlite.OpenFlags.EXResCode; + const conn = try zqlite.open(path, flags); + try conn.execNoArgs("PRAGMA foreign_keys = ON"); + try conn.execNoArgs("PRAGMA journal_mode = WAL"); + try migrate(allocator, conn, .{@embedFile("migrations/01.sql")}); + return conn; +} + +fn migrate(allocator: std.mem.Allocator, conn: zqlite.Conn, migrations: [1][]const u8) !void { + var version: i64 = 0; + if (try conn.row("PRAGMA user_version", .{})) |row| { + defer row.deinit(); + version = row.int(0); + } + + // Start transaction + try conn.transaction(); + errdefer conn.rollback(); + + for (migrations, 1..) |migration, i| { + const v: i64 = @intCast(i); + if (version < v) { + std.debug.print("Applying migration: {d}\n", .{i}); + + var it = std.mem.splitScalar(u8, migration, ';'); + while (it.next()) |statement| { + if (!is_statement_empty(statement)) { + try conn.exec(statement, .{}); + } + } + + try set_version(allocator, conn, @intCast(i)); + } + } + + // Commit transaction + try conn.commit(); +} + +fn is_statement_empty(str: []const u8) bool { + for (str) |c| { + if (c == ' ') continue; + if (c == '\n') continue; + if (c == '\t') continue; + return false; + } + return true; +} + +fn set_version(allocator: std.mem.Allocator, conn: zqlite.Conn, version: i64) !void { + const string = try std.fmt.allocPrint( + allocator, + "PRAGMA user_version = {d}", + .{version}, + ); + defer allocator.free(string); + try conn.exec(string, .{}); +} diff --git a/backend/src/repos/users_repo.zig b/backend/src/repos/users_repo.zig new file mode 100644 index 0000000..0f4ea82 --- /dev/null +++ b/backend/src/repos/users_repo.zig @@ -0,0 +1,90 @@ +const std = @import("std"); +const zqlite = @import("zqlite"); +const bcrypt = std.crypto.pwhash.bcrypt; +const rand = std.crypto.random; + +const token_length: usize = 20; + +pub const User = struct { + email: []const u8, + name: []const u8, +}; + +pub fn get_user(allocator: std.mem.Allocator, conn: zqlite.Conn, login_token: []const u8) !?User { + const query = + \\SELECT email, name + \\FROM users + \\WHERE login_token = ? + ; + + if (try conn.row(query, .{login_token})) |row| { + defer row.deinit(); + return User{ + .email = try allocator.dupe(u8, row.text(0)), + .name = try allocator.dupe(u8, row.text(1)), + }; + } else { + return null; + } +} + +pub fn check_password(allocator: std.mem.Allocator, conn: zqlite.Conn, email: []const u8, password: []const u8) !?User { + const query = + \\SELECT password_hash, email, name + \\FROM users + \\WHERE email = ? + ; + + if (try conn.row(query, .{email})) |row| { + defer row.deinit(); + const hash = row.text(0); + const verify_options = bcrypt.VerifyOptions{ .silently_truncate_password = false }; + std.time.sleep(100000000 + rand.intRangeAtMost(u32, 0, 100000000)); + bcrypt.strVerify(hash, password, verify_options) catch { + return null; + }; + return User{ + .email = try allocator.dupe(u8, row.text(1)), + .name = try allocator.dupe(u8, row.text(2)), + }; + } else { + std.time.sleep(500000000 + rand.intRangeAtMost(u32, 0, 500000000)); + return null; + } +} + +pub fn generate_login_token(allocator: std.mem.Allocator, conn: zqlite.Conn, email: []const u8) ![]const u8 { + const query = + \\UPDATE users + \\SET login_token = ?, updated_at = datetime() + \\WHERE email = ? + ; + + const token = try random_token(allocator); + try conn.exec(query, .{ token, email }); + return token; +} + +fn random_token(allocator: std.mem.Allocator) ![]const u8 { + // Generate random bytes + var bytes: [token_length]u8 = undefined; + rand.bytes(&bytes); + + // Encode as base64 + const Encoder = std.base64.standard.Encoder; + const encoded_length = Encoder.calcSize(token_length); + const token = try allocator.alloc(u8, encoded_length); + _ = Encoder.encode(token, &bytes); + + return token; +} + +pub fn remove_login_token(conn: zqlite.Conn, email: []const u8) !void { + const query = + \\ UPDATE users + \\ SET login_token = NULL, updated_at = datetime() + \\ WHERE email = ? + ; + + try conn.exec(query, .{email}); +} diff --git a/backend/src/services/auth_service.zig b/backend/src/services/auth_service.zig new file mode 100644 index 0000000..1a39584 --- /dev/null +++ b/backend/src/services/auth_service.zig @@ -0,0 +1,31 @@ +const httpz = @import("httpz"); + +const common = @import("common.zig"); +const users_repo = @import("../repos/users_repo.zig"); + +const Login = struct { email: []const u8, password: []const u8 }; + +pub fn login(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void { + const payload = try common.with_body(Login, req); + + const user = try users_repo.check_password(res.arena, env.conn, payload.email, payload.password) orelse return common.ServiceError.Forbidden; + const login_token = try users_repo.generate_login_token(res.arena, env.conn, payload.email); + try res.setCookie("token", login_token, .{ + .max_age = 31 * 24 * 60 * 60, // 31 days in seconds + .secure = env.secure_tokens, + .http_only = true, + .same_site = .strict, + }); + try res.json(user, .{}); +} + +pub fn logout(env: *common.Env, _: *httpz.Request, res: *httpz.Response) !void { + const user = env.user orelse return common.ServiceError.NotFound; + try users_repo.remove_login_token(env.conn, user.email); + try res.setCookie("token", "", .{ + .max_age = 0, // Expires immediately + .secure = env.secure_tokens, + .http_only = true, + .same_site = .strict, + }); +} diff --git a/backend/src/services/common.zig b/backend/src/services/common.zig new file mode 100644 index 0000000..42d18e9 --- /dev/null +++ b/backend/src/services/common.zig @@ -0,0 +1,30 @@ +const std = @import("std"); +const zqlite = @import("zqlite"); +const httpz = @import("httpz"); + +const users_repo = @import("../repos/users_repo.zig"); + +pub const Env = struct { + conn: zqlite.Conn, + secure_tokens: bool, + user: ?users_repo.User, +}; + +pub const ServiceError = error{ + BadRequest, + NotFound, + Forbidden, +}; + +pub fn with_body(comptime T: type, req: *httpz.Request) !T { + if (req.body()) |body| { + if (std.json.parseFromSlice(T, req.arena, body, .{})) |parsed| { + defer parsed.deinit(); + return parsed.value; + } else |_| { + return ServiceError.BadRequest; + } + } else { + return ServiceError.BadRequest; + } +} diff --git a/backend/src/services/handler.zig b/backend/src/services/handler.zig new file mode 100644 index 0000000..e8c92d4 --- /dev/null +++ b/backend/src/services/handler.zig @@ -0,0 +1,69 @@ +const httpz = @import("httpz"); +const std = @import("std"); +const zqlite = @import("zqlite"); + +const common = @import("common.zig"); +const static = @import("static.zig"); +const users_repo = @import("../repos/users_repo.zig"); + +pub const RouteData = struct { + is_public: bool, +}; + +pub const Handler = struct { + conn: zqlite.Conn, + secure_tokens: bool, + + pub fn dispatch(self: *Handler, action: httpz.Action(*common.Env), req: *httpz.Request, res: *httpz.Response) !void { + var user: ?users_repo.User = null; + if (!is_route_public(req)) { + const cookies = req.cookies(); + + const login_token = cookies.get("token") orelse return common.ServiceError.Forbidden; + user = try users_repo.get_user(res.arena, self.conn, login_token) orelse return common.ServiceError.Forbidden; + } + + var env = common.Env{ .conn = self.conn, .secure_tokens = self.secure_tokens, .user = user }; + + try action(&env, req, res); + } + + pub fn notFound(handler: *Handler, req: *httpz.Request, res: *httpz.Response) !void { + const path = req.url.path; + if (path.len >= 5 and std.mem.eql(u8, path[0..5], "/api/")) { + return common.ServiceError.NotFound; + } else { + // non API route, let client router take care of that + var env = common.Env{ .conn = handler.conn, .secure_tokens = handler.secure_tokens, .user = null }; + try static.index(&env, req, res); + } + } + + pub fn uncaughtError(_: *Handler, req: *httpz.Request, res: *httpz.Response, err: anyerror) void { + switch (err) { + common.ServiceError.BadRequest => error_response(res, 400, "Bad Request"), + common.ServiceError.NotFound => error_response(res, 404, "Not Found"), + common.ServiceError.Forbidden => error_response(res, 403, "Forbidden"), + else => { + std.debug.print("Internal Server Error at {s}: {}\n", .{ req.url.path, err }); + error_response(res, 500, "Internal Server Error"); + }, + } + } +}; + +fn is_route_public(req: *httpz.Request) bool { + if (req.route_data) |rd| { + const route_data: *const RouteData = @ptrCast(@alignCast(rd)); + return route_data.is_public; + } else { + return false; + } +} + +fn error_response(res: *httpz.Response, code: u16, message: []const u8) void { + res.status = code; + res.json(.{ .message = message }, .{}) catch { + res.body = message; + }; +} diff --git a/backend/src/services/maps_service.zig b/backend/src/services/maps_service.zig new file mode 100644 index 0000000..d634383 --- /dev/null +++ b/backend/src/services/maps_service.zig @@ -0,0 +1,39 @@ +const httpz = @import("httpz"); + +const maps_repo = @import("../repos/maps_repo.zig"); +const markers_repo = @import("../repos/markers_repo.zig"); +const common = @import("common.zig"); + +pub fn list(env: *common.Env, _: *httpz.Request, res: *httpz.Response) !void { + const maps = try maps_repo.get_maps(res.arena, env.conn); + try res.json(maps.items, .{}); +} + +pub fn get(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id").?; + const map = try maps_repo.get_map(res.arena, env.conn, id); + try res.json(map, .{}); +} + +const CreateMap = struct { name: []const u8 }; + +pub fn create(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void { + const payload = try common.with_body(CreateMap, req); + const map = try maps_repo.create(res.arena, env.conn, payload.name); + try res.json(map, .{}); +} + +const UpdateMap = struct { name: []const u8 }; + +pub fn update(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id").?; + const payload = try common.with_body(UpdateMap, req); + const map = try maps_repo.update(env.conn, id, payload.name); + try res.json(map, .{}); +} + +pub fn delete(env: *common.Env, req: *httpz.Request, _: *httpz.Response) !void { + const id = req.param("id").?; + try markers_repo.delete_by_map_id(env.conn, id); + try maps_repo.delete(env.conn, id); +} diff --git a/backend/src/services/markers_service.zig b/backend/src/services/markers_service.zig new file mode 100644 index 0000000..9e69682 --- /dev/null +++ b/backend/src/services/markers_service.zig @@ -0,0 +1,31 @@ +const httpz = @import("httpz"); + +const markers_repo = @import("../repos/markers_repo.zig"); +const common = @import("common.zig"); + +pub fn list_by_map(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void { + const query = try req.query(); + const map_id = query.get("map").?; + const maps = try markers_repo.get_markers(res.arena, env.conn, map_id); + try res.json(maps.items, .{}); +} + +pub fn create(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void { + const query = try req.query(); + const map_id = query.get("map").?; + const payload = try common.with_body(markers_repo.Payload, req); + const marker = try markers_repo.create(res.arena, env.conn, map_id, payload); + try res.json(marker, .{}); +} + +pub fn update(env: *common.Env, req: *httpz.Request, res: *httpz.Response) !void { + const id = req.param("id").?; + const payload = try common.with_body(markers_repo.Payload, req); + const map = try markers_repo.update(env.conn, id, payload); + try res.json(map, .{}); +} + +pub fn delete(env: *common.Env, req: *httpz.Request, _: *httpz.Response) !void { + const id = req.param("id").?; + try markers_repo.delete(env.conn, id); +} diff --git a/backend/src/services/static.zig b/backend/src/services/static.zig new file mode 100644 index 0000000..f236737 --- /dev/null +++ b/backend/src/services/static.zig @@ -0,0 +1,32 @@ +const httpz = @import("httpz"); +const std = @import("std"); + +const common = @import("common.zig"); + +pub fn index(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void { + try static_file(res, "public/index.html", httpz.ContentType.HTML); +} + +pub fn main_css(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void { + try static_file(res, "public/main.css", httpz.ContentType.CSS); +} + +pub fn main_js(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void { + try static_file(res, "public/main.js", httpz.ContentType.JS); +} + +pub fn icon_png(_: *common.Env, _: *httpz.Request, res: *httpz.Response) !void { + try static_file(res, "public/icon.png", httpz.ContentType.PNG); +} + +fn static_file(res: *httpz.Response, path: []const u8, content_type: httpz.ContentType) !void { + const file = try std.fs.cwd().openFile(path, .{}); + defer file.close(); + + const stat = try file.stat(); + const buf: []u8 = try file.readToEndAlloc(res.arena, stat.size); + res.body = buf; + + res.content_type = content_type; + res.header("cache-control", "no-cache, no-store, must-revalidate"); +} diff --git a/backend/src/services/users_service.zig b/backend/src/services/users_service.zig new file mode 100644 index 0000000..8547437 --- /dev/null +++ b/backend/src/services/users_service.zig @@ -0,0 +1,8 @@ +const httpz = @import("httpz"); + +const common = @import("common.zig"); + +pub fn get_user(env: *common.Env, _: *httpz.Request, res: *httpz.Response) !void { + const user = env.user orelse return common.ServiceError.NotFound; + try res.json(user, .{}); +} diff --git a/bin/dev b/bin/dev new file mode 100755 index 0000000..5b0a3b9 --- /dev/null +++ b/bin/dev @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd "$(dirname $0)/.." +PROJECT="maps" + +if [ "${1:-}" = "start" ]; then + + tmuxinator stop "$PROJECT" 2>/dev/null || true + tmuxinator start + +elif [ "${1:-}" = "stop" ]; then + + fuser -k 3000/tcp || true + tmuxinator stop "$PROJECT" + +else + + echo "Usage: $0 start|stop" + exit 1 + +fi diff --git a/bin/dev-server b/bin/dev-server deleted file mode 100755 index 82686ae..0000000 --- a/bin/dev-server +++ /dev/null @@ -1,16 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -# Run server - -python -m http.server --directory public 8000 & -trap "fuser -k 8000/tcp" EXIT - -# Watch TypeScript - -CHECK="echo Checking TypeScript… && tsc --checkJs" -BUILD="esbuild --bundle src/main.ts --target=es2017 --outdir=public" -watchexec \ - --clear \ - --watch src \ - -- "$CHECK && $BUILD" diff --git a/flake.lock b/flake.lock index bc595cc..21f2804 100644 --- a/flake.lock +++ b/flake.lock @@ -1,12 +1,31 @@ { "nodes": { + "flake-compat": { + "flake": false, + "locked": { + "lastModified": 1696426674, + "narHash": "sha256-kvjfFW7WAETZlt09AgDn1MrtKzP7t90Vf7vypd3OL1U=", + "owner": "edolstra", + "repo": "flake-compat", + "rev": "0f9255e01c2351cc7d116c072cb317785dd33b33", + "type": "github" + }, + "original": { + "owner": "edolstra", + "repo": "flake-compat", + "type": "github" + } + }, "flake-utils": { + "inputs": { + "systems": "systems" + }, "locked": { - "lastModified": 1656928814, - "narHash": "sha256-RIFfgBuKz6Hp89yRr7+NR5tzIAbn52h8vT6vXkYjZoM=", + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", "owner": "numtide", "repo": "flake-utils", - "rev": "7e2a3b3dfd9af950a856d66b0a7d01e3c18aa249", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", "type": "github" }, "original": { @@ -17,11 +36,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1659715246, - "narHash": "sha256-nWZOGZR4Cl4MdetOdnn8GOLxXw5L/V+unWV0KF5SqD0=", + "lastModified": 1741258105, + "narHash": "sha256-dYL3Mu7szy6mtcdhx7yu+hIpDKJIR4wRfa0YAmpdizc=", "owner": "nixos", "repo": "nixpkgs", - "rev": "ab403f07d359394c175218f66eac826e91b56565", + "rev": "1bf64f7f98c982885dc479fe76780a3ae60e5f7b", "type": "github" }, "original": { @@ -33,7 +52,47 @@ "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "zig": "zig" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + }, + "zig": { + "inputs": { + "flake-compat": "flake-compat", + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ] + }, + "locked": { + "lastModified": 1741221068, + "narHash": "sha256-sV/DSudiZSSaamUjANqGpHRUaVKhkraMGuXXP68WCHE=", + "owner": "mitchellh", + "repo": "zig-overlay", + "rev": "890dc8f72daea3f146babead98dd4fff614ddea7", + "type": "github" + }, + "original": { + "owner": "mitchellh", + "repo": "zig-overlay", + "type": "github" } } }, diff --git a/flake.nix b/flake.nix index 325ed60..6cc8636 100644 --- a/flake.nix +++ b/flake.nix @@ -1,21 +1,48 @@ { - inputs = { - nixpkgs.url = "github:nixos/nixpkgs"; - flake-utils.url = "github:numtide/flake-utils"; - }; + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils.url = "github:numtide/flake-utils"; + zig = { + url = "github:mitchellh/zig-overlay"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; + }; - outputs = { self, nixpkgs, flake-utils }: - flake-utils.lib.eachDefaultSystem - (system: - let pkgs = nixpkgs.legacyPackages.${system}; - in { devShell = pkgs.mkShell { - buildInputs = with pkgs; [ - nodePackages.typescript - python3 - psmisc # fuser - esbuild - watchexec - ]; - }; } - ); + outputs = { self, nixpkgs, flake-utils, zig, ... }: + flake-utils.lib.eachDefaultSystem (system: + let + overlays = [ + (final: prev: { + zigpkgs = zig.packages.${prev.system}; + }) + ]; + pkgs = import nixpkgs { + inherit system overlays; + }; + in + with pkgs; + { + devShell = mkShell { + buildInputs = [ + # Common + watchexec + + # Frontend + esbuild + nodePackages.typescript + dart-sass + + # Backend + psmisc # fuser + sqlite + zigpkgs.master + + # Tooling + tmux + tmuxinator + ]; + }; + } + ); } diff --git a/frontend/Makefile b/frontend/Makefile new file mode 100644 index 0000000..53c25e4 --- /dev/null +++ b/frontend/Makefile @@ -0,0 +1,16 @@ +build: ../backend/public/index.html ../backend/public/main.css ../backend/public/main.js ../backend/public/icon.png + +../backend/public/index.html: html/index.html + cp $^ $@ + +../backend/public/main.css: $(shell find styles) + bin/compile-sass + +../backend/public/main.js: $(shell find ts) + bin/compile-ts + +../backend/public/icon.png: images/icon.png + cp $^ $@ + +clean: + rm -f ../backend/public/* diff --git a/frontend/bin/compile-sass b/frontend/bin/compile-sass new file mode 100755 index 0000000..79d5415 --- /dev/null +++ b/frontend/bin/compile-sass @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +set -euo pipefail +sass --no-error-css styles/main.sass ../backend/public/main.css diff --git a/frontend/bin/compile-ts b/frontend/bin/compile-ts new file mode 100755 index 0000000..0e1d62a --- /dev/null +++ b/frontend/bin/compile-ts @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +set -euo pipefail + +cd ts +tsc --noEmit +esbuild --bundle src/main.ts > ../../backend/public/main.js diff --git a/frontend/bin/dev-server b/frontend/bin/dev-server new file mode 100755 index 0000000..4819033 --- /dev/null +++ b/frontend/bin/dev-server @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Killing watchexec ourselves, it may not be done otherwise. +function finish { + if [ -n "${LIVE_SERVER_PID:-}" ]; then + kill "$LIVE_SERVER_PID" > /dev/null 2>&1 + fi +} + +trap finish EXIT + +watchexec \ + --clear \ + --watch ts \ + --watch html \ + --watch images \ + --watch styles \ + --debounce 100ms \ + -- make & +LIVE_SERVER_PID="$!" + +while true; do sleep 1; done diff --git a/frontend/html/index.html b/frontend/html/index.html new file mode 100644 index 0000000..68fdca5 --- /dev/null +++ b/frontend/html/index.html @@ -0,0 +1,11 @@ + + + + +Maps + + + + + + diff --git a/frontend/images/icon.png b/frontend/images/icon.png new file mode 100644 index 0000000..80bcd74 Binary files /dev/null and b/frontend/images/icon.png differ diff --git a/frontend/styles/colors.sass b/frontend/styles/colors.sass new file mode 100644 index 0000000..5aef1c4 --- /dev/null +++ b/frontend/styles/colors.sass @@ -0,0 +1,7 @@ +$black: #333 +$red: #cf5c56 +$brown: #a04642 +$green: #9fd2a5 +$blue: #6ca2a4 +$gray-dark: #bbb +$gray: #eee diff --git a/frontend/styles/form.sass b/frontend/styles/form.sass new file mode 100644 index 0000000..fc25019 --- /dev/null +++ b/frontend/styles/form.sass @@ -0,0 +1,123 @@ +@use 'colors' +@use 'sass:color'; + +$input-height: 2.5rem +$input-border-radius: 0.125rem +$input-border-color: colors.$gray-dark +$input-color-height: 0.7 * $input-height + +.g-Label + display: flex + flex-direction: column + margin-bottom: 1rem + font-size: 0.9rem + + & > :first-child + margin-top: 0.25rem + +.g-Input + display: flex + height: $input-height + border: 1px solid $input-border-color + border-radius: $input-border-radius + padding: 0 0.5rem + font-size: inherit + font-family: inherit + + &[type="color"] + padding: 0 + border: none + width: 100px + height: $input-color-height + +.g-Textarea + padding: 0.5rem + resize: vertical + font-size: inherit + font-family: inherit + height: $input-height + min-height: $input-height + +.g-Select + display: flex + height: $input-height + border: 1px solid $input-border-color + border-radius: $input-border-radius + padding: 0 0.5rem + font-size: inherit + +.g-Button + display: flex + align-items: center + justify-content: center + padding: 0.5rem + height: $input-height + cursor: pointer + border: none + border-radius: $input-border-radius + color: black + font-size: 1rem + + &:disabled + background-color: colors.$gray + cursor: default + + &--Primary + background-color: colors.$green + color: white + &:not(:disabled):hover + background-color: color.scale(colors.$green, $lightness: 10%) + + &--Danger + background-color: colors.$red + color: white + &:not(:disabled):hover + background-color: color.scale(colors.$red, $lightness: 10%) + + &--FullWidth + width: 100% + +.g-ColorLine + display: flex + gap: 2rem + align-items: center + +.g-ColorButtons + display: flex + gap: 1rem + +.g-ColorButton + border-radius: 50% + width: $input-color-height + height: $input-color-height + border: none + margin-top: 0.25rem + cursor: pointer + +.g-FormError + color: colors.$red + margin-bottom: 2rem + text-align: center + +.g-Form__SubmitParent + position: relative + + .g-Button--Primary + .g-Form__SubmitSpinner + background-color: colors.$green + + .g-Button--Danger + .g-Form__SubmitSpinner + background-color: colors.$red + + & > .g-Form__SubmitSpinner + position: absolute + top: 0 + bottom: 0 + left: 0 + right: 0 + display: flex + align-items: center + justify-content: center + + svg + height: $input-height * 0.6 + fill: white diff --git a/frontend/styles/leaflet.css b/frontend/styles/leaflet.css new file mode 100644 index 0000000..981874b --- /dev/null +++ b/frontend/styles/leaflet.css @@ -0,0 +1,656 @@ +/* required styles */ + +.leaflet-pane, +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-tile-container, +.leaflet-pane > svg, +.leaflet-pane > canvas, +.leaflet-zoom-box, +.leaflet-image-layer, +.leaflet-layer { + position: absolute; + left: 0; + top: 0; + } +.leaflet-container { + overflow: hidden; + } +.leaflet-tile, +.leaflet-marker-icon, +.leaflet-marker-shadow { + -webkit-user-select: none; + -moz-user-select: none; + user-select: none; + -webkit-user-drag: none; + } +/* Prevents IE11 from highlighting tiles in blue */ +.leaflet-tile::selection { + background: transparent; +} +/* Safari renders non-retina tile on retina better with this, but Chrome is worse */ +.leaflet-safari .leaflet-tile { + image-rendering: -webkit-optimize-contrast; + } +/* hack that prevents hw layers "stretching" when loading new tiles */ +.leaflet-safari .leaflet-tile-container { + width: 1600px; + height: 1600px; + -webkit-transform-origin: 0 0; + } +.leaflet-marker-icon, +.leaflet-marker-shadow { + display: block; + } +/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */ +/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */ +.leaflet-container .leaflet-overlay-pane svg { + max-width: none !important; + max-height: none !important; + } +.leaflet-container .leaflet-marker-pane img, +.leaflet-container .leaflet-shadow-pane img, +.leaflet-container .leaflet-tile-pane img, +.leaflet-container img.leaflet-image-layer, +.leaflet-container .leaflet-tile { + max-width: none !important; + max-height: none !important; + width: auto; + padding: 0; + } + +.leaflet-container.leaflet-touch-zoom { + -ms-touch-action: pan-x pan-y; + touch-action: pan-x pan-y; + } +.leaflet-container.leaflet-touch-drag { + -ms-touch-action: pinch-zoom; + /* Fallback for FF which doesn't support pinch-zoom */ + touch-action: none; + touch-action: pinch-zoom; +} +.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom { + -ms-touch-action: none; + touch-action: none; +} +.leaflet-container { + -webkit-tap-highlight-color: transparent; +} +.leaflet-container a { + -webkit-tap-highlight-color: rgba(51, 181, 229, 0.4); +} +.leaflet-tile { + filter: inherit; + visibility: hidden; + } +.leaflet-tile-loaded { + visibility: inherit; + } +.leaflet-zoom-box { + width: 0; + height: 0; + -moz-box-sizing: border-box; + box-sizing: border-box; + z-index: 800; + } +/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */ +.leaflet-overlay-pane svg { + -moz-user-select: none; + } + +.leaflet-pane { z-index: 400; } + +.leaflet-tile-pane { z-index: 200; } +.leaflet-overlay-pane { z-index: 400; } +.leaflet-shadow-pane { z-index: 500; } +.leaflet-marker-pane { z-index: 600; } +.leaflet-tooltip-pane { z-index: 650; } +.leaflet-popup-pane { z-index: 700; } + +.leaflet-map-pane canvas { z-index: 100; } +.leaflet-map-pane svg { z-index: 200; } + +.leaflet-vml-shape { + width: 1px; + height: 1px; + } +.lvml { + behavior: url(#default#VML); + display: inline-block; + position: absolute; + } + + +/* control positioning */ + +.leaflet-control { + position: relative; + z-index: 800; + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } +.leaflet-top, +.leaflet-bottom { + position: absolute; + z-index: 1000; + pointer-events: none; + } +.leaflet-top { + top: 0; + } +.leaflet-right { + right: 0; + } +.leaflet-bottom { + bottom: 0; + } +.leaflet-left { + left: 0; + } +.leaflet-control { + float: left; + clear: both; + } +.leaflet-right .leaflet-control { + float: right; + } +.leaflet-top .leaflet-control { + margin-top: 10px; + } +.leaflet-bottom .leaflet-control { + margin-bottom: 10px; + } +.leaflet-left .leaflet-control { + margin-left: 10px; + } +.leaflet-right .leaflet-control { + margin-right: 10px; + } + + +/* zoom and fade animations */ + +.leaflet-fade-anim .leaflet-popup { + opacity: 0; + -webkit-transition: opacity 0.2s linear; + -moz-transition: opacity 0.2s linear; + transition: opacity 0.2s linear; + } +.leaflet-fade-anim .leaflet-map-pane .leaflet-popup { + opacity: 1; + } +.leaflet-zoom-animated { + -webkit-transform-origin: 0 0; + -ms-transform-origin: 0 0; + transform-origin: 0 0; + } +svg.leaflet-zoom-animated { + will-change: transform; +} + +.leaflet-zoom-anim .leaflet-zoom-animated { + -webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1); + -moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1); + transition: transform 0.25s cubic-bezier(0,0,0.25,1); + } +.leaflet-zoom-anim .leaflet-tile, +.leaflet-pan-anim .leaflet-tile { + -webkit-transition: none; + -moz-transition: none; + transition: none; + } + +.leaflet-zoom-anim .leaflet-zoom-hide { + visibility: hidden; + } + + +/* cursors */ + +.leaflet-interactive { + cursor: pointer; + } +.leaflet-grab { + cursor: -webkit-grab; + cursor: -moz-grab; + cursor: grab; + } +.leaflet-crosshair, +.leaflet-crosshair .leaflet-interactive { + cursor: crosshair; + } +.leaflet-popup-pane, +.leaflet-control { + cursor: auto; + } +.leaflet-dragging .leaflet-grab, +.leaflet-dragging .leaflet-grab .leaflet-interactive, +.leaflet-dragging .leaflet-marker-draggable { + cursor: move; + cursor: -webkit-grabbing; + cursor: -moz-grabbing; + cursor: grabbing; + } + +/* marker & overlays interactivity */ +.leaflet-marker-icon, +.leaflet-marker-shadow, +.leaflet-image-layer, +.leaflet-pane > svg path, +.leaflet-tile-container { + pointer-events: none; + } + +.leaflet-marker-icon.leaflet-interactive, +.leaflet-image-layer.leaflet-interactive, +.leaflet-pane > svg path.leaflet-interactive, +svg.leaflet-image-layer.leaflet-interactive path { + pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */ + pointer-events: auto; + } + +/* visual tweaks */ + +.leaflet-container { + background: #ddd; + outline-offset: 1px; + } +.leaflet-container a { + color: #0078A8; + } +.leaflet-zoom-box { + border: 2px dotted #38f; + background: rgba(255,255,255,0.5); + } + + +/* general typography */ +.leaflet-container { + font-family: "Helvetica Neue", Arial, Helvetica, sans-serif; + font-size: 12px; + font-size: 0.75rem; + line-height: 1.5; + } + + +/* general toolbar styles */ + +.leaflet-bar { + box-shadow: 0 1px 5px rgba(0,0,0,0.65); + border-radius: 4px; + } +.leaflet-bar a { + background-color: #fff; + border-bottom: 1px solid #ccc; + width: 26px; + height: 26px; + line-height: 26px; + display: block; + text-align: center; + text-decoration: none; + color: black; + } +.leaflet-bar a, +.leaflet-control-layers-toggle { + background-position: 50% 50%; + background-repeat: no-repeat; + display: block; + } +.leaflet-bar a:hover, +.leaflet-bar a:focus { + background-color: #f4f4f4; + } +.leaflet-bar a:first-child { + border-top-left-radius: 4px; + border-top-right-radius: 4px; + } +.leaflet-bar a:last-child { + border-bottom-left-radius: 4px; + border-bottom-right-radius: 4px; + border-bottom: none; + } +.leaflet-bar a.leaflet-disabled { + cursor: default; + background-color: #f4f4f4; + color: #bbb; + } + +.leaflet-touch .leaflet-bar a { + width: 30px; + height: 30px; + line-height: 30px; + } +.leaflet-touch .leaflet-bar a:first-child { + border-top-left-radius: 2px; + border-top-right-radius: 2px; + } +.leaflet-touch .leaflet-bar a:last-child { + border-bottom-left-radius: 2px; + border-bottom-right-radius: 2px; + } + +/* zoom control */ + +.leaflet-control-zoom-in, +.leaflet-control-zoom-out { + font: bold 18px 'Lucida Console', Monaco, monospace; + text-indent: 1px; + } + +.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out { + font-size: 22px; + } + + +/* layers control */ + +.leaflet-control-layers { + box-shadow: 0 1px 5px rgba(0,0,0,0.4); + background: #fff; + border-radius: 5px; + } +.leaflet-control-layers-toggle { + background-image: url(images/layers.png); + width: 36px; + height: 36px; + } +.leaflet-retina .leaflet-control-layers-toggle { + background-image: url(images/layers-2x.png); + background-size: 26px 26px; + } +.leaflet-touch .leaflet-control-layers-toggle { + width: 44px; + height: 44px; + } +.leaflet-control-layers .leaflet-control-layers-list, +.leaflet-control-layers-expanded .leaflet-control-layers-toggle { + display: none; + } +.leaflet-control-layers-expanded .leaflet-control-layers-list { + display: block; + position: relative; + } +.leaflet-control-layers-expanded { + padding: 6px 10px 6px 6px; + color: #333; + background: #fff; + } +.leaflet-control-layers-scrollbar { + overflow-y: scroll; + overflow-x: hidden; + padding-right: 5px; + } +.leaflet-control-layers-selector { + margin-top: 2px; + position: relative; + top: 1px; + } +.leaflet-control-layers label { + display: block; + font-size: 13px; + font-size: 1.08333em; + } +.leaflet-control-layers-separator { + height: 0; + border-top: 1px solid #ddd; + margin: 5px -10px 5px -6px; + } + +/* Default icon URLs */ +.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */ + background-image: url(images/marker-icon.png); + } + + +/* attribution and scale controls */ + +.leaflet-container .leaflet-control-attribution { + background: #fff; + background: rgba(255, 255, 255, 0.8); + margin: 0; + } +.leaflet-control-attribution, +.leaflet-control-scale-line { + padding: 0 5px; + color: #333; + line-height: 1.4; + } +.leaflet-control-attribution a { + text-decoration: none; + } +.leaflet-control-attribution a:hover, +.leaflet-control-attribution a:focus { + text-decoration: underline; + } +.leaflet-attribution-flag { + display: inline !important; + vertical-align: baseline !important; + width: 1em; + height: 0.6669em; + } +.leaflet-left .leaflet-control-scale { + margin-left: 5px; + } +.leaflet-bottom .leaflet-control-scale { + margin-bottom: 5px; + } +.leaflet-control-scale-line { + border: 2px solid #777; + border-top: none; + line-height: 1.1; + padding: 2px 5px 1px; + white-space: nowrap; + -moz-box-sizing: border-box; + box-sizing: border-box; + background: rgba(255, 255, 255, 0.8); + text-shadow: 1px 1px #fff; + } +.leaflet-control-scale-line:not(:first-child) { + border-top: 2px solid #777; + border-bottom: none; + margin-top: -2px; + } +.leaflet-control-scale-line:not(:first-child):not(:last-child) { + border-bottom: 2px solid #777; + } + +.leaflet-touch .leaflet-control-attribution, +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + box-shadow: none; + } +.leaflet-touch .leaflet-control-layers, +.leaflet-touch .leaflet-bar { + border: 2px solid rgba(0,0,0,0.2); + background-clip: padding-box; + } + + +/* popup */ + +.leaflet-popup { + position: absolute; + text-align: center; + margin-bottom: 20px; + } +.leaflet-popup-content-wrapper { + padding: 1px; + text-align: left; + border-radius: 12px; + } +.leaflet-popup-content { + margin: 13px 24px 13px 20px; + line-height: 1.3; + font-size: 13px; + font-size: 1.08333em; + min-height: 1px; + } +.leaflet-popup-content p { + margin: 17px 0; + margin: 1.3em 0; + } +.leaflet-popup-tip-container { + width: 40px; + height: 20px; + position: absolute; + left: 50%; + margin-top: -1px; + margin-left: -20px; + overflow: hidden; + pointer-events: none; + } +.leaflet-popup-tip { + width: 17px; + height: 17px; + padding: 1px; + + margin: -10px auto 0; + pointer-events: auto; + + -webkit-transform: rotate(45deg); + -moz-transform: rotate(45deg); + -ms-transform: rotate(45deg); + transform: rotate(45deg); + } +.leaflet-popup-content-wrapper, +.leaflet-popup-tip { + background: white; + color: #333; + box-shadow: 0 3px 14px rgba(0,0,0,0.4); + } +.leaflet-container a.leaflet-popup-close-button { + position: absolute; + top: 0; + right: 0; + border: none; + text-align: center; + width: 24px; + height: 24px; + font: 16px/24px Tahoma, Verdana, sans-serif; + color: #757575; + text-decoration: none; + background: transparent; + } +.leaflet-container a.leaflet-popup-close-button:hover, +.leaflet-container a.leaflet-popup-close-button:focus { + color: #585858; + } +.leaflet-popup-scrolled { + overflow: auto; + } + +.leaflet-oldie .leaflet-popup-content-wrapper { + -ms-zoom: 1; + } +.leaflet-oldie .leaflet-popup-tip { + width: 24px; + margin: 0 auto; + + -ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)"; + filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678); + } + +.leaflet-oldie .leaflet-control-zoom, +.leaflet-oldie .leaflet-control-layers, +.leaflet-oldie .leaflet-popup-content-wrapper, +.leaflet-oldie .leaflet-popup-tip { + border: 1px solid #999; + } + + +/* div icon */ + +.leaflet-div-icon { + background: #fff; + border: 1px solid #666; + } + + +/* Tooltip */ +/* Base styles for the element that has a tooltip */ +.leaflet-tooltip { + position: absolute; + padding: 6px; + background-color: #fff; + border: 1px solid #fff; + border-radius: 3px; + color: #222; + white-space: nowrap; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + pointer-events: none; + box-shadow: 0 1px 3px rgba(0,0,0,0.4); + } +.leaflet-tooltip.leaflet-interactive { + cursor: pointer; + pointer-events: auto; + } +.leaflet-tooltip-top:before, +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + position: absolute; + pointer-events: none; + border: 6px solid transparent; + background: transparent; + content: ""; + } + +/* Directions */ + +.leaflet-tooltip-bottom { + margin-top: 6px; +} +.leaflet-tooltip-top { + margin-top: -6px; +} +.leaflet-tooltip-bottom:before, +.leaflet-tooltip-top:before { + left: 50%; + margin-left: -6px; + } +.leaflet-tooltip-top:before { + bottom: 0; + margin-bottom: -12px; + border-top-color: #fff; + } +.leaflet-tooltip-bottom:before { + top: 0; + margin-top: -12px; + margin-left: -6px; + border-bottom-color: #fff; + } +.leaflet-tooltip-left { + margin-left: -6px; +} +.leaflet-tooltip-right { + margin-left: 6px; +} +.leaflet-tooltip-left:before, +.leaflet-tooltip-right:before { + top: 50%; + margin-top: -6px; + } +.leaflet-tooltip-left:before { + right: 0; + margin-right: -12px; + border-left-color: #fff; + } +.leaflet-tooltip-right:before { + left: 0; + margin-left: -12px; + border-right-color: #fff; + } + +/* Printing */ + +@media print { + /* Prevent printers from removing background-images of controls. */ + .leaflet-control { + -webkit-print-color-adjust: exact; + print-color-adjust: exact; + } + } diff --git a/frontend/styles/lib/icons.sass b/frontend/styles/lib/icons.sass new file mode 100644 index 0000000..d3fb2ef --- /dev/null +++ b/frontend/styles/lib/icons.sass @@ -0,0 +1,15 @@ +.g-Icon + &--Spin + animation: g-Icon--Spin 2s ease-in-out infinite, g-Icon--Appear 1s ease-in; + +@keyframes g-Icon--Spin + 0% + transform: rotate(0deg) + 100% + transform: rotate(360deg) + +@keyframes g-Icon--Appear + 0% + opacity: 0 + 100% + opacity: 1 diff --git a/frontend/styles/main.sass b/frontend/styles/main.sass new file mode 100644 index 0000000..7fa6963 --- /dev/null +++ b/frontend/styles/main.sass @@ -0,0 +1,71 @@ +@use 'colors' +@use 'form' +@use 'leaflet' + +// libs +@use 'lib/icons' + +// UI +@use 'ui/modal' +@use 'ui/layout' + +// Pages +@use 'pages/login' +@use 'pages/map' +@use 'pages/maps' + +/* General */ + +html + box-sizing: border-box + height: 100% + +*, *:before, *:after + box-sizing: inherit + +body + margin: 0 + font-family: sans-serif + font-size: 1.05rem + height: 100% + display: flex + flex-flow: column + +main + flex-grow: 1 + min-height: 0 + +header + display: flex + justify-content: space-between + width: 100% + background-color: colors.$brown + color: white + line-height: 3rem + + & > a + color: inherit + padding: 0 1rem + +.g-Logout__Button + border: none + background: transparent + font-size: inherit + color: white + cursor: pointer + padding: 0 1rem + height: 100% + + &:hover + text-decoration: underline + +a + color: colors.$blue + text-decoration: none + + &:hover + text-decoration: underline + +p + margin: 0 0 1rem + line-height: 1.4rem diff --git a/frontend/styles/pages/login.sass b/frontend/styles/pages/login.sass new file mode 100644 index 0000000..0f840ee --- /dev/null +++ b/frontend/styles/pages/login.sass @@ -0,0 +1,12 @@ +@use '../colors' + +.g-Login + max-width: 400px + margin: 7rem auto + + &__Title + text-align: center + margin: 0 0 2rem + color: colors.$red + font-weight: normal + font-size: 2rem diff --git a/frontend/styles/pages/map.sass b/frontend/styles/pages/map.sass new file mode 100644 index 0000000..ddfefcb --- /dev/null +++ b/frontend/styles/pages/map.sass @@ -0,0 +1,84 @@ +@use '../colors' + +// Leaflet cursor over the map +.leaflet-container + cursor: crosshair !important + &.leaflet-drag-target + cursor: grab !important + +.g-Map + display: flex + flex-flow: column + height: 100% + + .leaflet-container + flex-grow: 1 + min-height: 0 + + &__Footer + display: flex + align-items: center + justify-content: space-between + line-height: 3rem + background-color: colors.$black + color: white + padding: 0 1rem + + &__FooterButtons + display: flex + gap: 1rem + +.g-ContextMenu + z-index: 1000 + position: absolute + background-color: white + border-radius: var(--context-menu-border-radius) + border: 1px solid #333333 + $border-radius: 2px; + + &__Entry:first-child + border-top-left-radius: $border-radius + border-top-right-radius: $border-radius + + &__Entry:last-child + border-bottom-left-radius: $border-radius + border-bottom-right-radius: $border-radius + + &__Entry + padding: 0.5rem 1rem + + &:hover + background-color: #DDDDDD + cursor: pointer + +/* Marker icon */ + +.g-Marker + position: relative + width: 100% + height: 100% + + &__Base + position: absolute + width: 25px + bottom: calc(50%) + left: 50% + transform: translateX(-50%) + + &__Icon + position: absolute + bottom: 25px + display: flex + width: 12px + height: 12px + + &__Title + position: absolute + bottom: 47px + line-height: 0.8rem + left: 50% + transform: translateX(-50%) + color: black + font-weight: bold + text-align: center + width: 100px diff --git a/frontend/styles/pages/maps.sass b/frontend/styles/pages/maps.sass new file mode 100644 index 0000000..ad8ba24 --- /dev/null +++ b/frontend/styles/pages/maps.sass @@ -0,0 +1,2 @@ +.g-Maps + margin: 1rem diff --git a/frontend/styles/ui/layout.sass b/frontend/styles/ui/layout.sass new file mode 100644 index 0000000..93f5a69 --- /dev/null +++ b/frontend/styles/ui/layout.sass @@ -0,0 +1,25 @@ +@use '../colors' + +.g-Columns + display: flex + gap: 1rem + + & > * + flex-basis: 50% + flex-grow: 1 + +.g-Loading + display: flex + align-items: center + justify-content: center + height: 100% + + & > svg + width: 20px + +.g-Error + display: flex + align-items: center + justify-content: center + height: 100% + color: colors.$red diff --git a/frontend/styles/ui/modal.sass b/frontend/styles/ui/modal.sass new file mode 100644 index 0000000..81e91d6 --- /dev/null +++ b/frontend/styles/ui/modal.sass @@ -0,0 +1,57 @@ +@use '../colors' + +$transition-duration: 0.15s + +.g-Modal + width: 100vw + height: 100vh + position: fixed + top: 0 + left: 0 + display: flex + justify-content: center + align-items: center + z-index: 1000 // To be over leaflet + color: black + background-color: rgba(0, 0, 0, 0.5) + + &--Danger + .g-Modal__Content + border-left: 5px solid colors.$red + + &__Content + position: relative + background-color: white + width: 50% + min-width: 400px; + max-width: 600px; + border-radius: 0.2rem + line-height: normal + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.8) + + &__Header + display: flex + justify-content: space-between + align-items: center + padding: 1rem 2rem + border-bottom: 1px solid #EEE + font-weight: bold + + &__Body + padding: 2rem + max-height: 50vh + overflow-y: scroll + + &__Close + cursor: pointer + font-weight: bold + border-radius: 50% + border: 1px solid #EEE + background-color: transparent + width: 3rem + height: 3rem + font-size: 1.7rem + flex-shrink: 0 + + &__Close:hover, &__Close:focus + background-color: #EEE diff --git a/frontend/ts/src/lib/color.ts b/frontend/ts/src/lib/color.ts new file mode 100644 index 0000000..8798209 --- /dev/null +++ b/frontend/ts/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/frontend/ts/src/lib/form.ts b/frontend/ts/src/lib/form.ts new file mode 100644 index 0000000..a74ddca --- /dev/null +++ b/frontend/ts/src/lib/form.ts @@ -0,0 +1,175 @@ +import { h, Html, Rx, RxAble } from 'lib/rx' +import * as rx from 'lib/rx' +import * as L from 'lib/loadable' +import * as icons from 'lib/icons' + +interface InputParams { + label: string + type?: string + initValue?: string + select?: boolean + onUpdate: (value: string) => void + required?: boolean +} + +export function input({ label, type, initValue, select, onUpdate, required }: InputParams): Html { + return h('label', + { className: 'g-Label' }, + label, + h('input', + { + type: type ?? 'text', + className: 'g-Input', + onmount: (element: HTMLInputElement) => { + if (select) { + element.select() + } + }, + oninput: (event: Event) => { + let value = (event.target as HTMLInputElement).value + onUpdate(type == 'password' ? value : value.trim()) + }, + value: initValue, + required + } + ) + ) +} + +interface TextareaParams { + label: string + initValue?: string + select?: boolean + onUpdate: (value: string) => void + required?: boolean +} + +export function textarea({ label, initValue, select, onUpdate, required }: TextareaParams): Html { + return h('label', + { className: 'g-Label' }, + label, + h('textarea', + { + className: 'g-Textarea', + onmount: (element: HTMLInputElement) => { + if (select) { + element.select() + } + }, + oninput: (event: Event) => onUpdate((event.target as HTMLInputElement).value.trim()), + value: initValue, + required + } + ) + ) +} + +interface SelectParams { + label: string + initValue: string + values: { [key: string]: string } + onUpdate: (value: string) => void + required?: boolean +} + +export function select({ label, initValue, values, onUpdate, required }: SelectParams): Html { + let keys = Object.keys(values) + keys.sort((a, b) => values[a].localeCompare(values[b])) + + return h('label', + { className: 'g-Label' }, + label, + h('select', + { + className: 'g-Select', + onchange: (event: Event) => { + const element = event.target as HTMLSelectElement + onUpdate(element.value) + }, + required + }, + h('option', { label: ' ' }), + keys.map(key => + h('option', + { + value: key, + selected: initValue == key + }, + values[key] + ) + ) + ) + ) +} + +export function error(requestVar: Rx>): Html { + return requestVar.map(l => + L.isFailure(l) + ? h('div', { className: 'g-FormError' }, l.error) + : undefined + ) +} + +interface SubmitParams { + label: string + className?: string + disabled?: RxAble + requestVar?: Rx> +} + +export function submit({ label, className, disabled, requestVar }: SubmitParams): Html { + const loadingClassname = requestVar + ? requestVar.map(l => L.isLoading(l) ? 'g-Button--Loading' : '') + : rx.pure('') + + return h('div', + { className: 'g-Form__SubmitParent' }, + h('input', { + type: 'submit', + className: loadingClassname.map(lc => `g-Button g-Button--Primary ${className ?? ''} ${lc}`), + value: label, + disabled + }), + loadingClassname.map(l => + l && h('div', + { className: 'g-Form__SubmitSpinner' }, + icons.spinner() + ) + ) + ) +} + +interface ButtonParams { + style?: string + label: string + className?: string + disabled?: RxAble + requestVar?: Rx> + onClick: () => void, +} + +export function button({ style, label, className, disabled, requestVar, onClick }: ButtonParams): Html { + const loadingClassname = requestVar + ? requestVar.map(l => L.isLoading(l) ? 'g-Button--Loading' : '') + : rx.pure('') + + return h('div', + { className: 'g-Form__SubmitParent' }, + h('input', + { + type: 'button', + style, + className: loadingClassname.map(lc => `g-Button g-Button--Primary ${className ?? ''} ${lc}`), + disabled, + onclick: () => onClick(), + value: label + } + ), + loadingClassname.map(l => + l && h('div', + { className: 'g-Form__SubmitSpinner' }, + icons.spinner() + ) + ) + ) +} diff --git a/frontend/ts/src/lib/icons.ts b/frontend/ts/src/lib/icons.ts new file mode 100644 index 0000000..0b5128a --- /dev/null +++ b/frontend/ts/src/lib/icons.ts @@ -0,0 +1,94 @@ +// Font Awesome Free 5.15.4 by @fontawesome +// https://fontawesome.com License +// https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License) + +// Search for icons +// https://fontawesome.com/v4/icons/ + +import { s, Html, Attributes } from 'lib/rx' + +export function spinner(): Html { + return svg('spinner', { class: 'g-Icon--Spin' }) +} + +export function svg(key: string, attributes?: Attributes): Html { + const icon = values[key] + const attrs = attributes ?? {} + + if (icon !== undefined) { + if (attrs['viewBox'] === undefined) { + attrs['viewBox'] = icon.viewBox + } + + return s('svg', attrs, s('path', { d: icon.path })) + } +} + +interface Icon { + name: string + viewBox: string + path: string +} + +export const values: { [key: string]: Icon } = { + 'house': { + name: 'maison', + viewBox: '0 0 576 512', + path: 'M280.37 148.26L96 300.11V464a16 16 0 0 0 16 16l112.06-.29a16 16 0 0 0 15.92-16V368a16 16 0 0 1 16-16h64a16 16 0 0 1 16 16v95.64a16 16 0 0 0 16 16.05L464 480a16 16 0 0 0 16-16V300L295.67 148.26a12.19 12.19 0 0 0-15.3 0zM571.6 251.47L488 182.56V44.05a12 12 0 0 0-12-12h-56a12 12 0 0 0-12 12v72.61L318.47 43a48 48 0 0 0-61 0L4.34 251.47a12 12 0 0 0-1.6 16.9l25.5 31A12 12 0 0 0 45.15 301l235.22-193.74a12.19 12.19 0 0 1 15.3 0L530.9 301a12 12 0 0 0 16.9-1.6l25.5-31a12 12 0 0 0-1.7-16.93z' + }, + 'building': { + name: 'immeuble', + viewBox: '0 0 448 512', + path: 'M436 480h-20V24c0-13.255-10.745-24-24-24H56C42.745 0 32 10.745 32 24v456H12c-6.627 0-12 5.373-12 12v20h448v-20c0-6.627-5.373-12-12-12zM128 76c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12V76zm0 96c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12v-40zm52 148h-40c-6.627 0-12-5.373-12-12v-40c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40c0 6.627-5.373 12-12 12zm76 160h-64v-84c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v84zm64-172c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12v-40c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40zm0-96c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12v-40c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40zm0-96c0 6.627-5.373 12-12 12h-40c-6.627 0-12-5.373-12-12V76c0-6.627 5.373-12 12-12h40c6.627 0 12 5.373 12 12v40z' + }, + 'shopping': { + name: 'masagin', + viewBox: '0 0 576 512', + path: 'M528.12 301.319l47.273-208C578.806 78.301 567.391 64 551.99 64H159.208l-9.166-44.81C147.758 8.021 137.93 0 126.529 0H24C10.745 0 0 10.745 0 24v16c0 13.255 10.745 24 24 24h69.883l70.248 343.435C147.325 417.1 136 435.222 136 456c0 30.928 25.072 56 56 56s56-25.072 56-56c0-15.674-6.447-29.835-16.824-40h209.647C430.447 426.165 424 440.326 424 456c0 30.928 25.072 56 56 56s56-25.072 56-56c0-22.172-12.888-41.332-31.579-50.405l5.517-24.276c3.413-15.018-8.002-29.319-23.403-29.319H218.117l-6.545-32h293.145c11.206 0 20.92-7.754 23.403-18.681z' + }, + 'enveloppe': { + name: 'enveloppe', + viewBox: '0 0 512 512', + path: 'M502.3 190.8c3.9-3.1 9.7-.2 9.7 4.7V400c0 26.5-21.5 48-48 48H48c-26.5 0-48-21.5-48-48V195.6c0-5 5.7-7.8 9.7-4.7 22.4 17.4 52.1 39.5 154.1 113.6 21.1 15.4 56.7 47.8 92.2 47.6 35.7.3 72-32.8 92.3-47.6 102-74.1 131.6-96.3 154-113.7zM256 320c23.2.4 56.6-29.2 73.4-41.4 132.7-96.3 142.8-104.7 173.4-128.7 5.8-4.5 9.2-11.5 9.2-18.9v-19c0-26.5-21.5-48-48-48H48C21.5 64 0 85.5 0 112v19c0 7.4 3.4 14.3 9.2 18.9 30.6 23.9 40.7 32.4 173.4 128.7 16.8 12.2 50.2 41.8 73.4 41.4z' + }, + 'music': { + name: 'musique', + viewBox: '0 0 512 512', + path: 'M470.38 1.51L150.41 96A32 32 0 0 0 128 126.51v261.41A139 139 0 0 0 96 384c-53 0-96 28.66-96 64s43 64 96 64 96-28.66 96-64V214.32l256-75v184.61a138.4 138.4 0 0 0-32-3.93c-53 0-96 28.66-96 64s43 64 96 64 96-28.65 96-64V32a32 32 0 0 0-41.62-30.49z' + }, + 'medical': { + name: 'médical', + viewBox: '0 0 448 512', + path: 'M400 32H48C21.5 32 0 53.5 0 80v352c0 26.5 21.5 48 48 48h352c26.5 0 48-21.5 48-48V80c0-26.5-21.5-48-48-48zm-32 252c0 6.6-5.4 12-12 12h-92v92c0 6.6-5.4 12-12 12h-56c-6.6 0-12-5.4-12-12v-92H92c-6.6 0-12-5.4-12-12v-56c0-6.6 5.4-12 12-12h92v-92c0-6.6 5.4-12 12-12h56c6.6 0 12 5.4 12 12v92h92c6.6 0 12 5.4 12 12v56z' + }, + 'leaf': { + name: 'feuille', + viewBox: '0 0 576 512', + path: 'M546.2 9.7c-5.6-12.5-21.6-13-28.3-1.2C486.9 62.4 431.4 96 368 96h-80C182 96 96 182 96 288c0 7 .8 13.7 1.5 20.5C161.3 262.8 253.4 224 384 224c8.8 0 16 7.2 16 16s-7.2 16-16 16C132.6 256 26 410.1 2.4 468c-6.6 16.3 1.2 34.9 17.5 41.6 16.4 6.8 35-1.1 41.8-17.3 1.5-3.6 20.9-47.9 71.9-90.6 32.4 43.9 94 85.8 174.9 77.2C465.5 467.5 576 326.7 576 154.3c0-50.2-10.8-102.2-29.8-144.6z' + }, + 'utensils': { + name: 'ustensiles', + viewBox: '0 0 416 512', + path: 'M207.9 15.2c.8 4.7 16.1 94.5 16.1 128.8 0 52.3-27.8 89.6-68.9 104.6L168 486.7c.7 13.7-10.2 25.3-24 25.3H80c-13.7 0-24.7-11.5-24-25.3l12.9-238.1C27.7 233.6 0 196.2 0 144 0 109.6 15.3 19.9 16.1 15.2 19.3-5.1 61.4-5.4 64 16.3v141.2c1.3 3.4 15.1 3.2 16 0 1.4-25.3 7.9-139.2 8-141.8 3.3-20.8 44.7-20.8 47.9 0 .2 2.7 6.6 116.5 8 141.8.9 3.2 14.8 3.4 16 0V16.3c2.6-21.6 44.8-21.4 48-1.1zm119.2 285.7l-15 185.1c-1.2 14 9.9 26 23.9 26h56c13.3 0 24-10.7 24-24V24c0-13.2-10.7-24-24-24-82.5 0-221.4 178.5-64.9 300.9z' + }, + 'male': { + name: 'homme', + viewBox: '0 0 192 512', + path: 'M96 0c35.346 0 64 28.654 64 64s-28.654 64-64 64-64-28.654-64-64S60.654 0 96 0m48 144h-11.36c-22.711 10.443-49.59 10.894-73.28 0H48c-26.51 0-48 21.49-48 48v136c0 13.255 10.745 24 24 24h16v136c0 13.255 10.745 24 24 24h64c13.255 0 24-10.745 24-24V352h16c13.255 0 24-10.745 24-24V192c0-26.51-21.49-48-48-48z' + }, + 'female': { + name: 'femme', + viewBox: '0 0 256 512', + path: 'M128 0c35.346 0 64 28.654 64 64s-28.654 64-64 64c-35.346 0-64-28.654-64-64S92.654 0 128 0m119.283 354.179l-48-192A24 24 0 0 0 176 144h-11.36c-22.711 10.443-49.59 10.894-73.28 0H80a24 24 0 0 0-23.283 18.179l-48 192C4.935 369.305 16.383 384 32 384h56v104c0 13.255 10.745 24 24 24h32c13.255 0 24-10.745 24-24V384h56c15.591 0 27.071-14.671 23.283-29.821z' + }, + 'child': { + name: 'enfant', + viewBox: '0 0 384 512', + path: 'M120 72c0-39.765 32.235-72 72-72s72 32.235 72 72c0 39.764-32.235 72-72 72s-72-32.236-72-72zm254.627 1.373c-12.496-12.497-32.758-12.497-45.254 0L242.745 160H141.254L54.627 73.373c-12.496-12.497-32.758-12.497-45.254 0-12.497 12.497-12.497 32.758 0 45.255L104 213.254V480c0 17.673 14.327 32 32 32h16c17.673 0 32-14.327 32-32V368h16v112c0 17.673 14.327 32 32 32h16c17.673 0 32-14.327 32-32V213.254l94.627-94.627c12.497-12.497 12.497-32.757 0-45.254z' + }, + 'spinner': { + name: 'spinner', + viewBox: '0 0 512 512', + path: 'M304 48c0 26.51-21.49 48-48 48s-48-21.49-48-48 21.49-48 48-48 48 21.49 48 48zm-48 368c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zm208-208c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.49-48-48-48zM96 256c0-26.51-21.49-48-48-48S0 229.49 0 256s21.49 48 48 48 48-21.49 48-48zm12.922 99.078c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.491-48-48-48zm294.156 0c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48c0-26.509-21.49-48-48-48zM108.922 60.922c-26.51 0-48 21.49-48 48s21.49 48 48 48 48-21.49 48-48-21.491-48-48-48z' + }, +} diff --git a/frontend/ts/src/lib/leaflet.d.ts b/frontend/ts/src/lib/leaflet.d.ts new file mode 100644 index 0000000..76b88fd --- /dev/null +++ b/frontend/ts/src/lib/leaflet.d.ts @@ -0,0 +1,93 @@ +// Map + +export function map(element: string | HTMLElement, 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 + getLayers: () => Array +} + +export function featureGroup(xs?: Layer[]): 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} +} diff --git a/frontend/ts/src/lib/leaflet.js b/frontend/ts/src/lib/leaflet.js new file mode 100644 index 0000000..76fd23e --- /dev/null +++ b/frontend/ts/src/lib/leaflet.js @@ -0,0 +1,14421 @@ +// @ts-nocheck + +/* @preserve + * Leaflet 1.9.4, a JS library for interactive maps. https://leafletjs.com + * (c) 2010-2023 Vladimir Agafonkin, (c) 2010-2011 CloudMade + */ + +var version = "1.9.4"; + +/* + * @namespace Util + * + * Various utility functions, used by Leaflet internally. + */ + +// @function extend(dest: Object, src?: Object): Object +// Merges the properties of the `src` object (or multiple objects) into `dest` object and returns the latter. Has an `L.extend` shortcut. +function extend(dest) { + var i, j, len, src; + + for (j = 1, len = arguments.length; j < len; j++) { + src = arguments[j]; + for (i in src) { + dest[i] = src[i]; + } + } + return dest; +} + +// @function create(proto: Object, properties?: Object): Object +// Compatibility polyfill for [Object.create](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Object/create) +var create$2 = Object.create || (function () { + function F() {} + return function (proto) { + F.prototype = proto; + return new F(); + }; +})(); + +// @function bind(fn: Function, …): Function +// Returns a new function bound to the arguments passed, like [Function.prototype.bind](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Function/bind). +// Has a `L.bind()` shortcut. +function bind(fn, obj) { + var slice = Array.prototype.slice; + + if (fn.bind) { + return fn.bind.apply(fn, slice.call(arguments, 1)); + } + + var args = slice.call(arguments, 2); + + return function () { + return fn.apply(obj, args.length ? args.concat(slice.call(arguments)) : arguments); + }; +} + +// @property lastId: Number +// Last unique ID used by [`stamp()`](#util-stamp) +var lastId = 0; + +// @function stamp(obj: Object): Number +// Returns the unique ID of an object, assigning it one if it doesn't have it. +function stamp(obj) { + if (!('_leaflet_id' in obj)) { + obj['_leaflet_id'] = ++lastId; + } + return obj._leaflet_id; +} + +// @function throttle(fn: Function, time: Number, context: Object): Function +// Returns a function which executes function `fn` with the given scope `context` +// (so that the `this` keyword refers to `context` inside `fn`'s code). The function +// `fn` will be called no more than one time per given amount of `time`. The arguments +// received by the bound function will be any arguments passed when binding the +// function, followed by any arguments passed when invoking the bound function. +// Has an `L.throttle` shortcut. +function throttle(fn, time, context) { + var lock, args, wrapperFn, later; + + later = function () { + // reset lock and call if queued + lock = false; + if (args) { + wrapperFn.apply(context, args); + args = false; + } + }; + + wrapperFn = function () { + if (lock) { + // called too soon, queue to call later + args = arguments; + + } else { + // call and lock until later + fn.apply(context, arguments); + setTimeout(later, time); + lock = true; + } + }; + + return wrapperFn; +} + +// @function wrapNum(num: Number, range: Number[], includeMax?: Boolean): Number +// Returns the number `num` modulo `range` in such a way so it lies within +// `range[0]` and `range[1]`. The returned value will be always smaller than +// `range[1]` unless `includeMax` is set to `true`. +function wrapNum(x, range, includeMax) { + var max = range[1], + min = range[0], + d = max - min; + return x === max && includeMax ? x : ((x - min) % d + d) % d + min; +} + +// @function falseFn(): Function +// Returns a function which always returns `false`. +function falseFn() { return false; } + +// @function formatNum(num: Number, precision?: Number|false): Number +// Returns the number `num` rounded with specified `precision`. +// The default `precision` value is 6 decimal places. +// `false` can be passed to skip any processing (can be useful to avoid round-off errors). +function formatNum(num, precision) { + if (precision === false) { return num; } + var pow = Math.pow(10, precision === undefined ? 6 : precision); + return Math.round(num * pow) / pow; +} + +// @function trim(str: String): String +// Compatibility polyfill for [String.prototype.trim](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/String/Trim) +function trim(str) { + return str.trim ? str.trim() : str.replace(/^\s+|\s+$/g, ''); +} + +// @function splitWords(str: String): String[] +// Trims and splits the string on whitespace and returns the array of parts. +function splitWords(str) { + return trim(str).split(/\s+/); +} + +// @function setOptions(obj: Object, options: Object): Object +// Merges the given properties to the `options` of the `obj` object, returning the resulting options. See `Class options`. Has an `L.setOptions` shortcut. +function setOptions(obj, options) { + if (!Object.prototype.hasOwnProperty.call(obj, 'options')) { + obj.options = obj.options ? create$2(obj.options) : {}; + } + for (var i in options) { + obj.options[i] = options[i]; + } + return obj.options; +} + +// @function getParamString(obj: Object, existingUrl?: String, uppercase?: Boolean): String +// Converts an object into a parameter URL string, e.g. `{a: "foo", b: "bar"}` +// translates to `'?a=foo&b=bar'`. If `existingUrl` is set, the parameters will +// be appended at the end. If `uppercase` is `true`, the parameter names will +// be uppercased (e.g. `'?A=foo&B=bar'`) +function getParamString(obj, existingUrl, uppercase) { + var params = []; + for (var i in obj) { + params.push(encodeURIComponent(uppercase ? i.toUpperCase() : i) + '=' + encodeURIComponent(obj[i])); + } + return ((!existingUrl || existingUrl.indexOf('?') === -1) ? '?' : '&') + params.join('&'); +} + +var templateRe = /\{ *([\w_ -]+) *\}/g; + +// @function template(str: String, data: Object): String +// Simple templating facility, accepts a template string of the form `'Hello {a}, {b}'` +// and a data object like `{a: 'foo', b: 'bar'}`, returns evaluated string +// `('Hello foo, bar')`. You can also specify functions instead of strings for +// data values — they will be evaluated passing `data` as an argument. +function template(str, data) { + return str.replace(templateRe, function (str, key) { + var value = data[key]; + + if (value === undefined) { + throw new Error('No value provided for variable ' + str); + + } else if (typeof value === 'function') { + value = value(data); + } + return value; + }); +} + +// @function isArray(obj): Boolean +// Compatibility polyfill for [Array.isArray](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/isArray) +var isArray = Array.isArray || function (obj) { + return (Object.prototype.toString.call(obj) === '[object Array]'); +}; + +// @function indexOf(array: Array, el: Object): Number +// Compatibility polyfill for [Array.prototype.indexOf](https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/indexOf) +function indexOf(array, el) { + for (var i = 0; i < array.length; i++) { + if (array[i] === el) { return i; } + } + return -1; +} + +// @property emptyImageUrl: String +// Data URI string containing a base64-encoded empty GIF image. +// Used as a hack to free memory from unused images on WebKit-powered +// mobile devices (by setting image `src` to this string). +var emptyImageUrl = 'data:image/gif;base64,R0lGODlhAQABAAD/ACwAAAAAAQABAAACADs='; + +// inspired by https://paulirish.com/2011/requestanimationframe-for-smart-animating/ + +function getPrefixed(name) { + return window['webkit' + name] || window['moz' + name] || window['ms' + name]; +} + +var lastTime = 0; + +// fallback for IE 7-8 +function timeoutDefer(fn) { + var time = +new Date(), + timeToCall = Math.max(0, 16 - (time - lastTime)); + + lastTime = time + timeToCall; + return window.setTimeout(fn, timeToCall); +} + +var requestFn = window.requestAnimationFrame || getPrefixed('RequestAnimationFrame') || timeoutDefer; +var cancelFn = window.cancelAnimationFrame || getPrefixed('CancelAnimationFrame') || + getPrefixed('CancelRequestAnimationFrame') || function (id) { window.clearTimeout(id); }; + +// @function requestAnimFrame(fn: Function, context?: Object, immediate?: Boolean): Number +// Schedules `fn` to be executed when the browser repaints. `fn` is bound to +// `context` if given. When `immediate` is set, `fn` is called immediately if +// the browser doesn't have native support for +// [`window.requestAnimationFrame`](https://developer.mozilla.org/docs/Web/API/window/requestAnimationFrame), +// otherwise it's delayed. Returns a request ID that can be used to cancel the request. +function requestAnimFrame(fn, context, immediate) { + if (immediate && requestFn === timeoutDefer) { + fn.call(context); + } else { + return requestFn.call(window, bind(fn, context)); + } +} + +// @function cancelAnimFrame(id: Number): undefined +// Cancels a previous `requestAnimFrame`. See also [window.cancelAnimationFrame](https://developer.mozilla.org/docs/Web/API/window/cancelAnimationFrame). +function cancelAnimFrame(id) { + if (id) { + cancelFn.call(window, id); + } +} + +var Util = { + __proto__: null, + extend: extend, + create: create$2, + bind: bind, + get lastId () { return lastId; }, + stamp: stamp, + throttle: throttle, + wrapNum: wrapNum, + falseFn: falseFn, + formatNum: formatNum, + trim: trim, + splitWords: splitWords, + setOptions: setOptions, + getParamString: getParamString, + template: template, + isArray: isArray, + indexOf: indexOf, + emptyImageUrl: emptyImageUrl, + requestFn: requestFn, + cancelFn: cancelFn, + requestAnimFrame: requestAnimFrame, + cancelAnimFrame: cancelAnimFrame +}; + +// @class Class +// @aka L.Class + +// @section +// @uninheritable + +// Thanks to John Resig and Dean Edwards for inspiration! + +function Class() {} + +Class.extend = function (props) { + + // @function extend(props: Object): Function + // [Extends the current class](#class-inheritance) given the properties to be included. + // Returns a Javascript function that is a class constructor (to be called with `new`). + var NewClass = function () { + + setOptions(this); + + // call the constructor + if (this.initialize) { + this.initialize.apply(this, arguments); + } + + // call all constructor hooks + this.callInitHooks(); + }; + + var parentProto = NewClass.__super__ = this.prototype; + + var proto = create$2(parentProto); + proto.constructor = NewClass; + + NewClass.prototype = proto; + + // inherit parent's statics + for (var i in this) { + if (Object.prototype.hasOwnProperty.call(this, i) && i !== 'prototype' && i !== '__super__') { + NewClass[i] = this[i]; + } + } + + // mix static properties into the class + if (props.statics) { + extend(NewClass, props.statics); + } + + // mix includes into the prototype + if (props.includes) { + checkDeprecatedMixinEvents(props.includes); + extend.apply(null, [proto].concat(props.includes)); + } + + // mix given properties into the prototype + extend(proto, props); + delete proto.statics; + delete proto.includes; + + // merge options + if (proto.options) { + proto.options = parentProto.options ? create$2(parentProto.options) : {}; + extend(proto.options, props.options); + } + + proto._initHooks = []; + + // add method for calling all hooks + proto.callInitHooks = function () { + + if (this._initHooksCalled) { return; } + + if (parentProto.callInitHooks) { + parentProto.callInitHooks.call(this); + } + + this._initHooksCalled = true; + + for (var i = 0, len = proto._initHooks.length; i < len; i++) { + proto._initHooks[i].call(this); + } + }; + + return NewClass; +}; + + +// @function include(properties: Object): this +// [Includes a mixin](#class-includes) into the current class. +Class.include = function (props) { + var parentOptions = this.prototype.options; + extend(this.prototype, props); + if (props.options) { + this.prototype.options = parentOptions; + this.mergeOptions(props.options); + } + return this; +}; + +// @function mergeOptions(options: Object): this +// [Merges `options`](#class-options) into the defaults of the class. +Class.mergeOptions = function (options) { + extend(this.prototype.options, options); + return this; +}; + +// @function addInitHook(fn: Function): this +// Adds a [constructor hook](#class-constructor-hooks) to the class. +Class.addInitHook = function (fn) { // (Function) || (String, args...) + var args = Array.prototype.slice.call(arguments, 1); + + var init = typeof fn === 'function' ? fn : function () { + this[fn].apply(this, args); + }; + + this.prototype._initHooks = this.prototype._initHooks || []; + this.prototype._initHooks.push(init); + return this; +}; + +function checkDeprecatedMixinEvents(includes) { + /* global L: true */ + if (typeof L === 'undefined' || !L || !L.Mixin) { return; } + + includes = isArray(includes) ? includes : [includes]; + + for (var i = 0; i < includes.length; i++) { + if (includes[i] === L.Mixin.Events) { + console.warn('Deprecated include of L.Mixin.Events: ' + + 'this property will be removed in future releases, ' + + 'please inherit from L.Evented instead.', new Error().stack); + } + } +} + +/* + * @class Evented + * @aka L.Evented + * @inherits Class + * + * A set of methods shared between event-powered classes (like `Map` and `Marker`). Generally, events allow you to execute some function when something happens with an object (e.g. the user clicks on the map, causing the map to fire `'click'` event). + * + * @example + * + * ```js + * map.on('click', function(e) { + * alert(e.latlng); + * } ); + * ``` + * + * Leaflet deals with event listeners by reference, so if you want to add a listener and then remove it, define it as a function: + * + * ```js + * function onClick(e) { ... } + * + * map.on('click', onClick); + * map.off('click', onClick); + * ``` + */ + +var Events = { + /* @method on(type: String, fn: Function, context?: Object): this + * Adds a listener function (`fn`) to a particular event type of the object. You can optionally specify the context of the listener (object the this keyword will point to). You can also pass several space-separated types (e.g. `'click dblclick'`). + * + * @alternative + * @method on(eventMap: Object): this + * Adds a set of type/listener pairs, e.g. `{click: onClick, mousemove: onMouseMove}` + */ + on: function (types, fn, context) { + + // types can be a map of types/handlers + if (typeof types === 'object') { + for (var type in types) { + // we don't process space-separated events here for performance; + // it's a hot path since Layer uses the on(obj) syntax + this._on(type, types[type], fn); + } + + } else { + // types can be a string of space-separated words + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._on(types[i], fn, context); + } + } + + return this; + }, + + /* @method off(type: String, fn?: Function, context?: Object): this + * Removes a previously added listener function. If no function is specified, it will remove all the listeners of that particular event from the object. Note that if you passed a custom context to `on`, you must pass the same context to `off` in order to remove the listener. + * + * @alternative + * @method off(eventMap: Object): this + * Removes a set of type/listener pairs. + * + * @alternative + * @method off: this + * Removes all listeners to all events on the object. This includes implicitly attached events. + */ + off: function (types, fn, context) { + + if (!arguments.length) { + // clear all listeners if called without arguments + delete this._events; + + } else if (typeof types === 'object') { + for (var type in types) { + this._off(type, types[type], fn); + } + + } else { + types = splitWords(types); + + var removeAll = arguments.length === 1; + for (var i = 0, len = types.length; i < len; i++) { + if (removeAll) { + this._off(types[i]); + } else { + this._off(types[i], fn, context); + } + } + } + + return this; + }, + + // attach listener (without syntactic sugar now) + _on: function (type, fn, context, _once) { + if (typeof fn !== 'function') { + console.warn('wrong listener type: ' + typeof fn); + return; + } + + // check if fn already there + if (this._listens(type, fn, context) !== false) { + return; + } + + if (context === this) { + // Less memory footprint. + context = undefined; + } + + var newListener = {fn: fn, ctx: context}; + if (_once) { + newListener.once = true; + } + + this._events = this._events || {}; + this._events[type] = this._events[type] || []; + this._events[type].push(newListener); + }, + + _off: function (type, fn, context) { + var listeners, + i, + len; + + if (!this._events) { + return; + } + + listeners = this._events[type]; + if (!listeners) { + return; + } + + if (arguments.length === 1) { // remove all + if (this._firingCount) { + // Set all removed listeners to noop + // so they are not called if remove happens in fire + for (i = 0, len = listeners.length; i < len; i++) { + listeners[i].fn = falseFn; + } + } + // clear all listeners for a type if function isn't specified + delete this._events[type]; + return; + } + + if (typeof fn !== 'function') { + console.warn('wrong listener type: ' + typeof fn); + return; + } + + // find fn and remove it + var index = this._listens(type, fn, context); + if (index !== false) { + var listener = listeners[index]; + if (this._firingCount) { + // set the removed listener to noop so that's not called if remove happens in fire + listener.fn = falseFn; + + /* copy array in case events are being fired */ + this._events[type] = listeners = listeners.slice(); + } + listeners.splice(index, 1); + } + }, + + // @method fire(type: String, data?: Object, propagate?: Boolean): this + // Fires an event of the specified type. You can optionally provide a data + // object — the first argument of the listener function will contain its + // properties. The event can optionally be propagated to event parents. + fire: function (type, data, propagate) { + if (!this.listens(type, propagate)) { return this; } + + var event = extend({}, data, { + type: type, + target: this, + sourceTarget: data && data.sourceTarget || this + }); + + if (this._events) { + var listeners = this._events[type]; + if (listeners) { + this._firingCount = (this._firingCount + 1) || 1; + for (var i = 0, len = listeners.length; i < len; i++) { + var l = listeners[i]; + // off overwrites l.fn, so we need to copy fn to a var + var fn = l.fn; + if (l.once) { + this.off(type, fn, l.ctx); + } + fn.call(l.ctx || this, event); + } + + this._firingCount--; + } + } + + if (propagate) { + // propagate the event to parents (set with addEventParent) + this._propagateEvent(event); + } + + return this; + }, + + // @method listens(type: String, propagate?: Boolean): Boolean + // @method listens(type: String, fn: Function, context?: Object, propagate?: Boolean): Boolean + // Returns `true` if a particular event type has any listeners attached to it. + // The verification can optionally be propagated, it will return `true` if parents have the listener attached to it. + listens: function (type, fn, context, propagate) { + if (typeof type !== 'string') { + console.warn('"string" type argument expected'); + } + + // we don't overwrite the input `fn` value, because we need to use it for propagation + var _fn = fn; + if (typeof fn !== 'function') { + propagate = !!fn; + _fn = undefined; + context = undefined; + } + + var listeners = this._events && this._events[type]; + if (listeners && listeners.length) { + if (this._listens(type, _fn, context) !== false) { + return true; + } + } + + if (propagate) { + // also check parents for listeners if event propagates + for (var id in this._eventParents) { + if (this._eventParents[id].listens(type, fn, context, propagate)) { return true; } + } + } + return false; + }, + + // returns the index (number) or false + _listens: function (type, fn, context) { + if (!this._events) { + return false; + } + + var listeners = this._events[type] || []; + if (!fn) { + return !!listeners.length; + } + + if (context === this) { + // Less memory footprint. + context = undefined; + } + + for (var i = 0, len = listeners.length; i < len; i++) { + if (listeners[i].fn === fn && listeners[i].ctx === context) { + return i; + } + } + return false; + + }, + + // @method once(…): this + // Behaves as [`on(…)`](#evented-on), except the listener will only get fired once and then removed. + once: function (types, fn, context) { + + // types can be a map of types/handlers + if (typeof types === 'object') { + for (var type in types) { + // we don't process space-separated events here for performance; + // it's a hot path since Layer uses the on(obj) syntax + this._on(type, types[type], fn, true); + } + + } else { + // types can be a string of space-separated words + types = splitWords(types); + + for (var i = 0, len = types.length; i < len; i++) { + this._on(types[i], fn, context, true); + } + } + + return this; + }, + + // @method addEventParent(obj: Evented): this + // Adds an event parent - an `Evented` that will receive propagated events + addEventParent: function (obj) { + this._eventParents = this._eventParents || {}; + this._eventParents[stamp(obj)] = obj; + return this; + }, + + // @method removeEventParent(obj: Evented): this + // Removes an event parent, so it will stop receiving propagated events + removeEventParent: function (obj) { + if (this._eventParents) { + delete this._eventParents[stamp(obj)]; + } + return this; + }, + + _propagateEvent: function (e) { + for (var id in this._eventParents) { + this._eventParents[id].fire(e.type, extend({ + layer: e.target, + propagatedFrom: e.target + }, e), true); + } + } +}; + +// aliases; we should ditch those eventually + +// @method addEventListener(…): this +// Alias to [`on(…)`](#evented-on) +Events.addEventListener = Events.on; + +// @method removeEventListener(…): this +// Alias to [`off(…)`](#evented-off) + +// @method clearAllEventListeners(…): this +// Alias to [`off()`](#evented-off) +Events.removeEventListener = Events.clearAllEventListeners = Events.off; + +// @method addOneTimeEventListener(…): this +// Alias to [`once(…)`](#evented-once) +Events.addOneTimeEventListener = Events.once; + +// @method fireEvent(…): this +// Alias to [`fire(…)`](#evented-fire) +Events.fireEvent = Events.fire; + +// @method hasEventListeners(…): Boolean +// Alias to [`listens(…)`](#evented-listens) +Events.hasEventListeners = Events.listens; + +var Evented = Class.extend(Events); + +/* + * @class Point + * @aka L.Point + * + * Represents a point with `x` and `y` coordinates in pixels. + * + * @example + * + * ```js + * var point = L.point(200, 300); + * ``` + * + * All Leaflet methods and options that accept `Point` objects also accept them in a simple Array form (unless noted otherwise), so these lines are equivalent: + * + * ```js + * map.panBy([200, 300]); + * map.panBy(L.point(200, 300)); + * ``` + * + * Note that `Point` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Point(x, y, round) { + // @property x: Number; The `x` coordinate of the point + this.x = (round ? Math.round(x) : x); + // @property y: Number; The `y` coordinate of the point + this.y = (round ? Math.round(y) : y); +} + +var trunc = Math.trunc || function (v) { + return v > 0 ? Math.floor(v) : Math.ceil(v); +}; + +Point.prototype = { + + // @method clone(): Point + // Returns a copy of the current point. + clone: function () { + return new Point(this.x, this.y); + }, + + // @method add(otherPoint: Point): Point + // Returns the result of addition of the current and the given points. + add: function (point) { + // non-destructive, returns a new point + return this.clone()._add(toPoint(point)); + }, + + _add: function (point) { + // destructive, used directly for performance in situations where it's safe to modify existing point + this.x += point.x; + this.y += point.y; + return this; + }, + + // @method subtract(otherPoint: Point): Point + // Returns the result of subtraction of the given point from the current. + subtract: function (point) { + return this.clone()._subtract(toPoint(point)); + }, + + _subtract: function (point) { + this.x -= point.x; + this.y -= point.y; + return this; + }, + + // @method divideBy(num: Number): Point + // Returns the result of division of the current point by the given number. + divideBy: function (num) { + return this.clone()._divideBy(num); + }, + + _divideBy: function (num) { + this.x /= num; + this.y /= num; + return this; + }, + + // @method multiplyBy(num: Number): Point + // Returns the result of multiplication of the current point by the given number. + multiplyBy: function (num) { + return this.clone()._multiplyBy(num); + }, + + _multiplyBy: function (num) { + this.x *= num; + this.y *= num; + return this; + }, + + // @method scaleBy(scale: Point): Point + // Multiply each coordinate of the current point by each coordinate of + // `scale`. In linear algebra terms, multiply the point by the + // [scaling matrix](https://en.wikipedia.org/wiki/Scaling_%28geometry%29#Matrix_representation) + // defined by `scale`. + scaleBy: function (point) { + return new Point(this.x * point.x, this.y * point.y); + }, + + // @method unscaleBy(scale: Point): Point + // Inverse of `scaleBy`. Divide each coordinate of the current point by + // each coordinate of `scale`. + unscaleBy: function (point) { + return new Point(this.x / point.x, this.y / point.y); + }, + + // @method round(): Point + // Returns a copy of the current point with rounded coordinates. + round: function () { + return this.clone()._round(); + }, + + _round: function () { + this.x = Math.round(this.x); + this.y = Math.round(this.y); + return this; + }, + + // @method floor(): Point + // Returns a copy of the current point with floored coordinates (rounded down). + floor: function () { + return this.clone()._floor(); + }, + + _floor: function () { + this.x = Math.floor(this.x); + this.y = Math.floor(this.y); + return this; + }, + + // @method ceil(): Point + // Returns a copy of the current point with ceiled coordinates (rounded up). + ceil: function () { + return this.clone()._ceil(); + }, + + _ceil: function () { + this.x = Math.ceil(this.x); + this.y = Math.ceil(this.y); + return this; + }, + + // @method trunc(): Point + // Returns a copy of the current point with truncated coordinates (rounded towards zero). + trunc: function () { + return this.clone()._trunc(); + }, + + _trunc: function () { + this.x = trunc(this.x); + this.y = trunc(this.y); + return this; + }, + + // @method distanceTo(otherPoint: Point): Number + // Returns the cartesian distance between the current and the given points. + distanceTo: function (point) { + point = toPoint(point); + + var x = point.x - this.x, + y = point.y - this.y; + + return Math.sqrt(x * x + y * y); + }, + + // @method equals(otherPoint: Point): Boolean + // Returns `true` if the given point has the same coordinates. + equals: function (point) { + point = toPoint(point); + + return point.x === this.x && + point.y === this.y; + }, + + // @method contains(otherPoint: Point): Boolean + // Returns `true` if both coordinates of the given point are less than the corresponding current point coordinates (in absolute values). + contains: function (point) { + point = toPoint(point); + + return Math.abs(point.x) <= Math.abs(this.x) && + Math.abs(point.y) <= Math.abs(this.y); + }, + + // @method toString(): String + // Returns a string representation of the point for debugging purposes. + toString: function () { + return 'Point(' + + formatNum(this.x) + ', ' + + formatNum(this.y) + ')'; + } +}; + +// @factory L.point(x: Number, y: Number, round?: Boolean) +// Creates a Point object with the given `x` and `y` coordinates. If optional `round` is set to true, rounds the `x` and `y` values. + +// @alternative +// @factory L.point(coords: Number[]) +// Expects an array of the form `[x, y]` instead. + +// @alternative +// @factory L.point(coords: Object) +// Expects a plain object of the form `{x: Number, y: Number}` instead. +function toPoint(x, y, round) { + if (x instanceof Point) { + return x; + } + if (isArray(x)) { + return new Point(x[0], x[1]); + } + if (x === undefined || x === null) { + return x; + } + if (typeof x === 'object' && 'x' in x && 'y' in x) { + return new Point(x.x, x.y); + } + return new Point(x, y, round); +} + +/* + * @class Bounds + * @aka L.Bounds + * + * Represents a rectangular area in pixel coordinates. + * + * @example + * + * ```js + * var p1 = L.point(10, 10), + * p2 = L.point(40, 60), + * bounds = L.bounds(p1, p2); + * ``` + * + * All Leaflet methods that accept `Bounds` objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * otherBounds.intersects([[10, 10], [40, 60]]); + * ``` + * + * Note that `Bounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function Bounds(a, b) { + if (!a) { return; } + + var points = b ? [a, b] : a; + + for (var i = 0, len = points.length; i < len; i++) { + this.extend(points[i]); + } +} + +Bounds.prototype = { + // @method extend(point: Point): this + // Extends the bounds to contain the given point. + + // @alternative + // @method extend(otherBounds: Bounds): this + // Extend the bounds to contain the given bounds + extend: function (obj) { + var min2, max2; + if (!obj) { return this; } + + if (obj instanceof Point || typeof obj[0] === 'number' || 'x' in obj) { + min2 = max2 = toPoint(obj); + } else { + obj = toBounds(obj); + min2 = obj.min; + max2 = obj.max; + + if (!min2 || !max2) { return this; } + } + + // @property min: Point + // The top left corner of the rectangle. + // @property max: Point + // The bottom right corner of the rectangle. + if (!this.min && !this.max) { + this.min = min2.clone(); + this.max = max2.clone(); + } else { + this.min.x = Math.min(min2.x, this.min.x); + this.max.x = Math.max(max2.x, this.max.x); + this.min.y = Math.min(min2.y, this.min.y); + this.max.y = Math.max(max2.y, this.max.y); + } + return this; + }, + + // @method getCenter(round?: Boolean): Point + // Returns the center point of the bounds. + getCenter: function (round) { + return toPoint( + (this.min.x + this.max.x) / 2, + (this.min.y + this.max.y) / 2, round); + }, + + // @method getBottomLeft(): Point + // Returns the bottom-left point of the bounds. + getBottomLeft: function () { + return toPoint(this.min.x, this.max.y); + }, + + // @method getTopRight(): Point + // Returns the top-right point of the bounds. + getTopRight: function () { // -> Point + return toPoint(this.max.x, this.min.y); + }, + + // @method getTopLeft(): Point + // Returns the top-left point of the bounds (i.e. [`this.min`](#bounds-min)). + getTopLeft: function () { + return this.min; // left, top + }, + + // @method getBottomRight(): Point + // Returns the bottom-right point of the bounds (i.e. [`this.max`](#bounds-max)). + getBottomRight: function () { + return this.max; // right, bottom + }, + + // @method getSize(): Point + // Returns the size of the given bounds + getSize: function () { + return this.max.subtract(this.min); + }, + + // @method contains(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle contains the given one. + // @alternative + // @method contains(point: Point): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { + var min, max; + + if (typeof obj[0] === 'number' || obj instanceof Point) { + obj = toPoint(obj); + } else { + obj = toBounds(obj); + } + + if (obj instanceof Bounds) { + min = obj.min; + max = obj.max; + } else { + min = max = obj; + } + + return (min.x >= this.min.x) && + (max.x <= this.max.x) && + (min.y >= this.min.y) && + (max.y <= this.max.y); + }, + + // @method intersects(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds + // intersect if they have at least one point in common. + intersects: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xIntersects = (max2.x >= min.x) && (min2.x <= max.x), + yIntersects = (max2.y >= min.y) && (min2.y <= max.y); + + return xIntersects && yIntersects; + }, + + // @method overlaps(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds + // overlap if their intersection is an area. + overlaps: function (bounds) { // (Bounds) -> Boolean + bounds = toBounds(bounds); + + var min = this.min, + max = this.max, + min2 = bounds.min, + max2 = bounds.max, + xOverlaps = (max2.x > min.x) && (min2.x < max.x), + yOverlaps = (max2.y > min.y) && (min2.y < max.y); + + return xOverlaps && yOverlaps; + }, + + // @method isValid(): Boolean + // Returns `true` if the bounds are properly initialized. + isValid: function () { + return !!(this.min && this.max); + }, + + + // @method pad(bufferRatio: Number): Bounds + // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. + // For example, a ratio of 0.5 extends the bounds by 50% in each direction. + // Negative values will retract the bounds. + pad: function (bufferRatio) { + var min = this.min, + max = this.max, + heightBuffer = Math.abs(min.x - max.x) * bufferRatio, + widthBuffer = Math.abs(min.y - max.y) * bufferRatio; + + + return toBounds( + toPoint(min.x - heightBuffer, min.y - widthBuffer), + toPoint(max.x + heightBuffer, max.y + widthBuffer)); + }, + + + // @method equals(otherBounds: Bounds): Boolean + // Returns `true` if the rectangle is equivalent to the given bounds. + equals: function (bounds) { + if (!bounds) { return false; } + + bounds = toBounds(bounds); + + return this.min.equals(bounds.getTopLeft()) && + this.max.equals(bounds.getBottomRight()); + }, +}; + + +// @factory L.bounds(corner1: Point, corner2: Point) +// Creates a Bounds object from two corners coordinate pairs. +// @alternative +// @factory L.bounds(points: Point[]) +// Creates a Bounds object from the given array of points. +function toBounds(a, b) { + if (!a || a instanceof Bounds) { + return a; + } + return new Bounds(a, b); +} + +/* + * @class LatLngBounds + * @aka L.LatLngBounds + * + * Represents a rectangular geographical area on a map. + * + * @example + * + * ```js + * var corner1 = L.latLng(40.712, -74.227), + * corner2 = L.latLng(40.774, -74.125), + * bounds = L.latLngBounds(corner1, corner2); + * ``` + * + * All Leaflet methods that accept LatLngBounds objects also accept them in a simple Array form (unless noted otherwise), so the bounds example above can be passed like this: + * + * ```js + * map.fitBounds([ + * [40.712, -74.227], + * [40.774, -74.125] + * ]); + * ``` + * + * Caution: if the area crosses the antimeridian (often confused with the International Date Line), you must specify corners _outside_ the [-180, 180] degrees longitude range. + * + * Note that `LatLngBounds` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLngBounds(corner1, corner2) { // (LatLng, LatLng) or (LatLng[]) + if (!corner1) { return; } + + var latlngs = corner2 ? [corner1, corner2] : corner1; + + for (var i = 0, len = latlngs.length; i < len; i++) { + this.extend(latlngs[i]); + } +} + +LatLngBounds.prototype = { + + // @method extend(latlng: LatLng): this + // Extend the bounds to contain the given point + + // @alternative + // @method extend(otherBounds: LatLngBounds): this + // Extend the bounds to contain the given bounds + extend: function (obj) { + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLng) { + sw2 = obj; + ne2 = obj; + + } else if (obj instanceof LatLngBounds) { + sw2 = obj._southWest; + ne2 = obj._northEast; + + if (!sw2 || !ne2) { return this; } + + } else { + return obj ? this.extend(toLatLng(obj) || toLatLngBounds(obj)) : this; + } + + if (!sw && !ne) { + this._southWest = new LatLng(sw2.lat, sw2.lng); + this._northEast = new LatLng(ne2.lat, ne2.lng); + } else { + sw.lat = Math.min(sw2.lat, sw.lat); + sw.lng = Math.min(sw2.lng, sw.lng); + ne.lat = Math.max(ne2.lat, ne.lat); + ne.lng = Math.max(ne2.lng, ne.lng); + } + + return this; + }, + + // @method pad(bufferRatio: Number): LatLngBounds + // Returns bounds created by extending or retracting the current bounds by a given ratio in each direction. + // For example, a ratio of 0.5 extends the bounds by 50% in each direction. + // Negative values will retract the bounds. + pad: function (bufferRatio) { + var sw = this._southWest, + ne = this._northEast, + heightBuffer = Math.abs(sw.lat - ne.lat) * bufferRatio, + widthBuffer = Math.abs(sw.lng - ne.lng) * bufferRatio; + + return new LatLngBounds( + new LatLng(sw.lat - heightBuffer, sw.lng - widthBuffer), + new LatLng(ne.lat + heightBuffer, ne.lng + widthBuffer)); + }, + + // @method getCenter(): LatLng + // Returns the center point of the bounds. + getCenter: function () { + return new LatLng( + (this._southWest.lat + this._northEast.lat) / 2, + (this._southWest.lng + this._northEast.lng) / 2); + }, + + // @method getSouthWest(): LatLng + // Returns the south-west point of the bounds. + getSouthWest: function () { + return this._southWest; + }, + + // @method getNorthEast(): LatLng + // Returns the north-east point of the bounds. + getNorthEast: function () { + return this._northEast; + }, + + // @method getNorthWest(): LatLng + // Returns the north-west point of the bounds. + getNorthWest: function () { + return new LatLng(this.getNorth(), this.getWest()); + }, + + // @method getSouthEast(): LatLng + // Returns the south-east point of the bounds. + getSouthEast: function () { + return new LatLng(this.getSouth(), this.getEast()); + }, + + // @method getWest(): Number + // Returns the west longitude of the bounds + getWest: function () { + return this._southWest.lng; + }, + + // @method getSouth(): Number + // Returns the south latitude of the bounds + getSouth: function () { + return this._southWest.lat; + }, + + // @method getEast(): Number + // Returns the east longitude of the bounds + getEast: function () { + return this._northEast.lng; + }, + + // @method getNorth(): Number + // Returns the north latitude of the bounds + getNorth: function () { + return this._northEast.lat; + }, + + // @method contains(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle contains the given one. + + // @alternative + // @method contains (latlng: LatLng): Boolean + // Returns `true` if the rectangle contains the given point. + contains: function (obj) { // (LatLngBounds) or (LatLng) -> Boolean + if (typeof obj[0] === 'number' || obj instanceof LatLng || 'lat' in obj) { + obj = toLatLng(obj); + } else { + obj = toLatLngBounds(obj); + } + + var sw = this._southWest, + ne = this._northEast, + sw2, ne2; + + if (obj instanceof LatLngBounds) { + sw2 = obj.getSouthWest(); + ne2 = obj.getNorthEast(); + } else { + sw2 = ne2 = obj; + } + + return (sw2.lat >= sw.lat) && (ne2.lat <= ne.lat) && + (sw2.lng >= sw.lng) && (ne2.lng <= ne.lng); + }, + + // @method intersects(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle intersects the given bounds. Two bounds intersect if they have at least one point in common. + intersects: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latIntersects = (ne2.lat >= sw.lat) && (sw2.lat <= ne.lat), + lngIntersects = (ne2.lng >= sw.lng) && (sw2.lng <= ne.lng); + + return latIntersects && lngIntersects; + }, + + // @method overlaps(otherBounds: LatLngBounds): Boolean + // Returns `true` if the rectangle overlaps the given bounds. Two bounds overlap if their intersection is an area. + overlaps: function (bounds) { + bounds = toLatLngBounds(bounds); + + var sw = this._southWest, + ne = this._northEast, + sw2 = bounds.getSouthWest(), + ne2 = bounds.getNorthEast(), + + latOverlaps = (ne2.lat > sw.lat) && (sw2.lat < ne.lat), + lngOverlaps = (ne2.lng > sw.lng) && (sw2.lng < ne.lng); + + return latOverlaps && lngOverlaps; + }, + + // @method toBBoxString(): String + // Returns a string with bounding box coordinates in a 'southwest_lng,southwest_lat,northeast_lng,northeast_lat' format. Useful for sending requests to web services that return geo data. + toBBoxString: function () { + return [this.getWest(), this.getSouth(), this.getEast(), this.getNorth()].join(','); + }, + + // @method equals(otherBounds: LatLngBounds, maxMargin?: Number): Boolean + // Returns `true` if the rectangle is equivalent (within a small margin of error) to the given bounds. The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (bounds, maxMargin) { + if (!bounds) { return false; } + + bounds = toLatLngBounds(bounds); + + return this._southWest.equals(bounds.getSouthWest(), maxMargin) && + this._northEast.equals(bounds.getNorthEast(), maxMargin); + }, + + // @method isValid(): Boolean + // Returns `true` if the bounds are properly initialized. + isValid: function () { + return !!(this._southWest && this._northEast); + } +}; + +// TODO International date line? + +// @factory L.latLngBounds(corner1: LatLng, corner2: LatLng) +// Creates a `LatLngBounds` object by defining two diagonally opposite corners of the rectangle. + +// @alternative +// @factory L.latLngBounds(latlngs: LatLng[]) +// Creates a `LatLngBounds` object defined by the geographical points it contains. Very useful for zooming the map to fit a particular set of locations with [`fitBounds`](#map-fitbounds). +function toLatLngBounds(a, b) { + if (a instanceof LatLngBounds) { + return a; + } + return new LatLngBounds(a, b); +} + +/* @class LatLng + * @aka L.LatLng + * + * Represents a geographical point with a certain latitude and longitude. + * + * @example + * + * ``` + * var latlng = L.latLng(50.5, 30.5); + * ``` + * + * All Leaflet methods that accept LatLng objects also accept them in a simple Array form and simple object form (unless noted otherwise), so these lines are equivalent: + * + * ``` + * map.panTo([50, 30]); + * map.panTo({lon: 30, lat: 50}); + * map.panTo({lat: 50, lng: 30}); + * map.panTo(L.latLng(50, 30)); + * ``` + * + * Note that `LatLng` does not inherit from Leaflet's `Class` object, + * which means new classes can't inherit from it, and new methods + * can't be added to it with the `include` function. + */ + +function LatLng(lat, lng, alt) { + if (isNaN(lat) || isNaN(lng)) { + throw new Error('Invalid LatLng object: (' + lat + ', ' + lng + ')'); + } + + // @property lat: Number + // Latitude in degrees + this.lat = +lat; + + // @property lng: Number + // Longitude in degrees + this.lng = +lng; + + // @property alt: Number + // Altitude in meters (optional) + if (alt !== undefined) { + this.alt = +alt; + } +} + +LatLng.prototype = { + // @method equals(otherLatLng: LatLng, maxMargin?: Number): Boolean + // Returns `true` if the given `LatLng` point is at the same position (within a small margin of error). The margin of error can be overridden by setting `maxMargin` to a small number. + equals: function (obj, maxMargin) { + if (!obj) { return false; } + + obj = toLatLng(obj); + + var margin = Math.max( + Math.abs(this.lat - obj.lat), + Math.abs(this.lng - obj.lng)); + + return margin <= (maxMargin === undefined ? 1.0E-9 : maxMargin); + }, + + // @method toString(): String + // Returns a string representation of the point (for debugging purposes). + toString: function (precision) { + return 'LatLng(' + + formatNum(this.lat, precision) + ', ' + + formatNum(this.lng, precision) + ')'; + }, + + // @method distanceTo(otherLatLng: LatLng): Number + // Returns the distance (in meters) to the given `LatLng` calculated using the [Spherical Law of Cosines](https://en.wikipedia.org/wiki/Spherical_law_of_cosines). + distanceTo: function (other) { + return Earth.distance(this, toLatLng(other)); + }, + + // @method wrap(): LatLng + // Returns a new `LatLng` object with the longitude wrapped so it's always between -180 and +180 degrees. + wrap: function () { + return Earth.wrapLatLng(this); + }, + + // @method toBounds(sizeInMeters: Number): LatLngBounds + // Returns a new `LatLngBounds` object in which each boundary is `sizeInMeters/2` meters apart from the `LatLng`. + toBounds: function (sizeInMeters) { + var latAccuracy = 180 * sizeInMeters / 40075017, + lngAccuracy = latAccuracy / Math.cos((Math.PI / 180) * this.lat); + + return toLatLngBounds( + [this.lat - latAccuracy, this.lng - lngAccuracy], + [this.lat + latAccuracy, this.lng + lngAccuracy]); + }, + + clone: function () { + return new LatLng(this.lat, this.lng, this.alt); + } +}; + + + +// @factory L.latLng(latitude: Number, longitude: Number, altitude?: Number): LatLng +// Creates an object representing a geographical point with the given latitude and longitude (and optionally altitude). + +// @alternative +// @factory L.latLng(coords: Array): LatLng +// Expects an array of the form `[Number, Number]` or `[Number, Number, Number]` instead. + +// @alternative +// @factory L.latLng(coords: Object): LatLng +// Expects an plain object of the form `{lat: Number, lng: Number}` or `{lat: Number, lng: Number, alt: Number}` instead. + +function toLatLng(a, b, c) { + if (a instanceof LatLng) { + return a; + } + if (isArray(a) && typeof a[0] !== 'object') { + if (a.length === 3) { + return new LatLng(a[0], a[1], a[2]); + } + if (a.length === 2) { + return new LatLng(a[0], a[1]); + } + return null; + } + if (a === undefined || a === null) { + return a; + } + if (typeof a === 'object' && 'lat' in a) { + return new LatLng(a.lat, 'lng' in a ? a.lng : a.lon, a.alt); + } + if (b === undefined) { + return null; + } + return new LatLng(a, b, c); +} + +/* + * @namespace CRS + * @crs L.CRS.Base + * Object that defines coordinate reference systems for projecting + * geographical points into pixel (screen) coordinates and back (and to + * coordinates in other units for [WMS](https://en.wikipedia.org/wiki/Web_Map_Service) services). See + * [spatial reference system](https://en.wikipedia.org/wiki/Spatial_reference_system). + * + * Leaflet defines the most usual CRSs by default. If you want to use a + * CRS not defined by default, take a look at the + * [Proj4Leaflet](https://github.com/kartena/Proj4Leaflet) plugin. + * + * Note that the CRS instances do not inherit from Leaflet's `Class` object, + * and can't be instantiated. Also, new classes can't inherit from them, + * and methods can't be added to them with the `include` function. + */ + +var CRS = { + // @method latLngToPoint(latlng: LatLng, zoom: Number): Point + // Projects geographical coordinates into pixel coordinates for a given zoom. + latLngToPoint: function (latlng, zoom) { + var projectedPoint = this.projection.project(latlng), + scale = this.scale(zoom); + + return this.transformation._transform(projectedPoint, scale); + }, + + // @method pointToLatLng(point: Point, zoom: Number): LatLng + // The inverse of `latLngToPoint`. Projects pixel coordinates on a given + // zoom into geographical coordinates. + pointToLatLng: function (point, zoom) { + var scale = this.scale(zoom), + untransformedPoint = this.transformation.untransform(point, scale); + + return this.projection.unproject(untransformedPoint); + }, + + // @method project(latlng: LatLng): Point + // Projects geographical coordinates into coordinates in units accepted for + // this CRS (e.g. meters for EPSG:3857, for passing it to WMS services). + project: function (latlng) { + return this.projection.project(latlng); + }, + + // @method unproject(point: Point): LatLng + // Given a projected coordinate returns the corresponding LatLng. + // The inverse of `project`. + unproject: function (point) { + return this.projection.unproject(point); + }, + + // @method scale(zoom: Number): Number + // Returns the scale used when transforming projected coordinates into + // pixel coordinates for a particular zoom. For example, it returns + // `256 * 2^zoom` for Mercator-based CRS. + scale: function (zoom) { + return 256 * Math.pow(2, zoom); + }, + + // @method zoom(scale: Number): Number + // Inverse of `scale()`, returns the zoom level corresponding to a scale + // factor of `scale`. + zoom: function (scale) { + return Math.log(scale / 256) / Math.LN2; + }, + + // @method getProjectedBounds(zoom: Number): Bounds + // Returns the projection's bounds scaled and transformed for the provided `zoom`. + getProjectedBounds: function (zoom) { + if (this.infinite) { return null; } + + var b = this.projection.bounds, + s = this.scale(zoom), + min = this.transformation.transform(b.min, s), + max = this.transformation.transform(b.max, s); + + return new Bounds(min, max); + }, + + // @method distance(latlng1: LatLng, latlng2: LatLng): Number + // Returns the distance between two geographical coordinates. + + // @property code: String + // Standard code name of the CRS passed into WMS services (e.g. `'EPSG:3857'`) + // + // @property wrapLng: Number[] + // An array of two numbers defining whether the longitude (horizontal) coordinate + // axis wraps around a given range and how. Defaults to `[-180, 180]` in most + // geographical CRSs. If `undefined`, the longitude axis does not wrap around. + // + // @property wrapLat: Number[] + // Like `wrapLng`, but for the latitude (vertical) axis. + + // wrapLng: [min, max], + // wrapLat: [min, max], + + // @property infinite: Boolean + // If true, the coordinate space will be unbounded (infinite in both axes) + infinite: false, + + // @method wrapLatLng(latlng: LatLng): LatLng + // Returns a `LatLng` where lat and lng has been wrapped according to the + // CRS's `wrapLat` and `wrapLng` properties, if they are outside the CRS's bounds. + wrapLatLng: function (latlng) { + var lng = this.wrapLng ? wrapNum(latlng.lng, this.wrapLng, true) : latlng.lng, + lat = this.wrapLat ? wrapNum(latlng.lat, this.wrapLat, true) : latlng.lat, + alt = latlng.alt; + + return new LatLng(lat, lng, alt); + }, + + // @method wrapLatLngBounds(bounds: LatLngBounds): LatLngBounds + // Returns a `LatLngBounds` with the same size as the given one, ensuring + // that its center is within the CRS's bounds. + // Only accepts actual `L.LatLngBounds` instances, not arrays. + wrapLatLngBounds: function (bounds) { + var center = bounds.getCenter(), + newCenter = this.wrapLatLng(center), + latShift = center.lat - newCenter.lat, + lngShift = center.lng - newCenter.lng; + + if (latShift === 0 && lngShift === 0) { + return bounds; + } + + var sw = bounds.getSouthWest(), + ne = bounds.getNorthEast(), + newSw = new LatLng(sw.lat - latShift, sw.lng - lngShift), + newNe = new LatLng(ne.lat - latShift, ne.lng - lngShift); + + return new LatLngBounds(newSw, newNe); + } +}; + +/* + * @namespace CRS + * @crs L.CRS.Earth + * + * Serves as the base for CRS that are global such that they cover the earth. + * Can only be used as the base for other CRS and cannot be used directly, + * since it does not have a `code`, `projection` or `transformation`. `distance()` returns + * meters. + */ + +var Earth = extend({}, CRS, { + wrapLng: [-180, 180], + + // Mean Earth Radius, as recommended for use by + // the International Union of Geodesy and Geophysics, + // see https://rosettacode.org/wiki/Haversine_formula + R: 6371000, + + // distance between two geographical points using spherical law of cosines approximation + distance: function (latlng1, latlng2) { + var rad = Math.PI / 180, + lat1 = latlng1.lat * rad, + lat2 = latlng2.lat * rad, + sinDLat = Math.sin((latlng2.lat - latlng1.lat) * rad / 2), + sinDLon = Math.sin((latlng2.lng - latlng1.lng) * rad / 2), + a = sinDLat * sinDLat + Math.cos(lat1) * Math.cos(lat2) * sinDLon * sinDLon, + c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + return this.R * c; + } +}); + +/* + * @namespace Projection + * @projection L.Projection.SphericalMercator + * + * Spherical Mercator projection — the most common projection for online maps, + * used by almost all free and commercial tile providers. Assumes that Earth is + * a sphere. Used by the `EPSG:3857` CRS. + */ + +var earthRadius = 6378137; + +var SphericalMercator = { + + R: earthRadius, + MAX_LATITUDE: 85.0511287798, + + project: function (latlng) { + var d = Math.PI / 180, + max = this.MAX_LATITUDE, + lat = Math.max(Math.min(max, latlng.lat), -max), + sin = Math.sin(lat * d); + + return new Point( + this.R * latlng.lng * d, + this.R * Math.log((1 + sin) / (1 - sin)) / 2); + }, + + unproject: function (point) { + var d = 180 / Math.PI; + + return new LatLng( + (2 * Math.atan(Math.exp(point.y / this.R)) - (Math.PI / 2)) * d, + point.x * d / this.R); + }, + + bounds: (function () { + var d = earthRadius * Math.PI; + return new Bounds([-d, -d], [d, d]); + })() +}; + +/* + * @class Transformation + * @aka L.Transformation + * + * Represents an affine transformation: a set of coefficients `a`, `b`, `c`, `d` + * for transforming a point of a form `(x, y)` into `(a*x + b, c*y + d)` and doing + * the reverse. Used by Leaflet in its projections code. + * + * @example + * + * ```js + * var transformation = L.transformation(2, 5, -1, 10), + * p = L.point(1, 2), + * p2 = transformation.transform(p), // L.point(7, 8) + * p3 = transformation.untransform(p2); // L.point(1, 2) + * ``` + */ + + +// factory new L.Transformation(a: Number, b: Number, c: Number, d: Number) +// Creates a `Transformation` object with the given coefficients. +function Transformation(a, b, c, d) { + if (isArray(a)) { + // use array properties + this._a = a[0]; + this._b = a[1]; + this._c = a[2]; + this._d = a[3]; + return; + } + this._a = a; + this._b = b; + this._c = c; + this._d = d; +} + +Transformation.prototype = { + // @method transform(point: Point, scale?: Number): Point + // Returns a transformed point, optionally multiplied by the given scale. + // Only accepts actual `L.Point` instances, not arrays. + transform: function (point, scale) { // (Point, Number) -> Point + return this._transform(point.clone(), scale); + }, + + // destructive transform (faster) + _transform: function (point, scale) { + scale = scale || 1; + point.x = scale * (this._a * point.x + this._b); + point.y = scale * (this._c * point.y + this._d); + return point; + }, + + // @method untransform(point: Point, scale?: Number): Point + // Returns the reverse transformation of the given point, optionally divided + // by the given scale. Only accepts actual `L.Point` instances, not arrays. + untransform: function (point, scale) { + scale = scale || 1; + return new Point( + (point.x / scale - this._b) / this._a, + (point.y / scale - this._d) / this._c); + } +}; + +// factory L.transformation(a: Number, b: Number, c: Number, d: Number) + +// @factory L.transformation(a: Number, b: Number, c: Number, d: Number) +// Instantiates a Transformation object with the given coefficients. + +// @alternative +// @factory L.transformation(coefficients: Array): Transformation +// Expects an coefficients array of the form +// `[a: Number, b: Number, c: Number, d: Number]`. + +function toTransformation(a, b, c, d) { + return new Transformation(a, b, c, d); +} + +/* + * @namespace CRS + * @crs L.CRS.EPSG3857 + * + * The most common CRS for online maps, used by almost all free and commercial + * tile providers. Uses Spherical Mercator projection. Set in by default in + * Map's `crs` option. + */ + +var EPSG3857 = extend({}, Earth, { + code: 'EPSG:3857', + projection: SphericalMercator, + + transformation: (function () { + var scale = 0.5 / (Math.PI * SphericalMercator.R); + return toTransformation(scale, 0.5, -scale, 0.5); + }()) +}); + +var EPSG900913 = extend({}, EPSG3857, { + code: 'EPSG:900913' +}); + +// @namespace SVG; @section +// There are several static functions which can be called without instantiating L.SVG: + +// @function create(name: String): SVGElement +// Returns a instance of [SVGElement](https://developer.mozilla.org/docs/Web/API/SVGElement), +// corresponding to the class name passed. For example, using 'line' will return +// an instance of [SVGLineElement](https://developer.mozilla.org/docs/Web/API/SVGLineElement). +function svgCreate(name) { + return document.createElementNS('http://www.w3.org/2000/svg', name); +} + +// @function pointsToPath(rings: Point[], closed: Boolean): String +// Generates a SVG path string for multiple rings, with each ring turning +// into "M..L..L.." instructions +function pointsToPath(rings, closed) { + var str = '', + i, j, len, len2, points, p; + + for (i = 0, len = rings.length; i < len; i++) { + points = rings[i]; + + for (j = 0, len2 = points.length; j < len2; j++) { + p = points[j]; + str += (j ? 'L' : 'M') + p.x + ' ' + p.y; + } + + // closes the ring for polygons; "x" is VML syntax + str += closed ? (Browser.svg ? 'z' : 'x') : ''; + } + + // SVG complains about empty path strings + return str || 'M0 0'; +} + +/* + * @namespace Browser + * @aka L.Browser + * + * A namespace with static properties for browser/feature detection used by Leaflet internally. + * + * @example + * + * ```js + * if (L.Browser.ielt9) { + * alert('Upgrade your browser, dude!'); + * } + * ``` + */ + +var style = document.documentElement.style; + +// @property ie: Boolean; `true` for all Internet Explorer versions (not Edge). +var ie = 'ActiveXObject' in window; + +// @property ielt9: Boolean; `true` for Internet Explorer versions less than 9. +var ielt9 = ie && !document.addEventListener; + +// @property edge: Boolean; `true` for the Edge web browser. +var edge = 'msLaunchUri' in navigator && !('documentMode' in document); + +// @property webkit: Boolean; +// `true` for webkit-based browsers like Chrome and Safari (including mobile versions). +var webkit = userAgentContains('webkit'); + +// @property android: Boolean +// **Deprecated.** `true` for any browser running on an Android platform. +var android = userAgentContains('android'); + +// @property android23: Boolean; **Deprecated.** `true` for browsers running on Android 2 or Android 3. +var android23 = userAgentContains('android 2') || userAgentContains('android 3'); + +/* See https://stackoverflow.com/a/17961266 for details on detecting stock Android */ +var webkitVer = parseInt(/WebKit\/([0-9]+)|$/.exec(navigator.userAgent)[1], 10); // also matches AppleWebKit +// @property androidStock: Boolean; **Deprecated.** `true` for the Android stock browser (i.e. not Chrome) +var androidStock = android && userAgentContains('Google') && webkitVer < 537 && !('AudioNode' in window); + +// @property opera: Boolean; `true` for the Opera browser +var opera = !!window.opera; + +// @property chrome: Boolean; `true` for the Chrome browser. +var chrome = !edge && userAgentContains('chrome'); + +// @property gecko: Boolean; `true` for gecko-based browsers like Firefox. +var gecko = userAgentContains('gecko') && !webkit && !opera && !ie; + +// @property safari: Boolean; `true` for the Safari browser. +var safari = !chrome && userAgentContains('safari'); + +var phantom = userAgentContains('phantom'); + +// @property opera12: Boolean +// `true` for the Opera browser supporting CSS transforms (version 12 or later). +var opera12 = 'OTransition' in style; + +// @property win: Boolean; `true` when the browser is running in a Windows platform +var win = navigator.platform.indexOf('Win') === 0; + +// @property ie3d: Boolean; `true` for all Internet Explorer versions supporting CSS transforms. +var ie3d = ie && ('transition' in style); + +// @property webkit3d: Boolean; `true` for webkit-based browsers supporting CSS transforms. +var webkit3d = ('WebKitCSSMatrix' in window) && ('m11' in new window.WebKitCSSMatrix()) && !android23; + +// @property gecko3d: Boolean; `true` for gecko-based browsers supporting CSS transforms. +var gecko3d = 'MozPerspective' in style; + +// @property any3d: Boolean +// `true` for all browsers supporting CSS transforms. +var any3d = !window.L_DISABLE_3D && (ie3d || webkit3d || gecko3d) && !opera12 && !phantom; + +// @property mobile: Boolean; `true` for all browsers running in a mobile device. +var mobile = typeof orientation !== 'undefined' || userAgentContains('mobile'); + +// @property mobileWebkit: Boolean; `true` for all webkit-based browsers in a mobile device. +var mobileWebkit = mobile && webkit; + +// @property mobileWebkit3d: Boolean +// `true` for all webkit-based browsers in a mobile device supporting CSS transforms. +var mobileWebkit3d = mobile && webkit3d; + +// @property msPointer: Boolean +// `true` for browsers implementing the Microsoft touch events model (notably IE10). +var msPointer = !window.PointerEvent && window.MSPointerEvent; + +// @property pointer: Boolean +// `true` for all browsers supporting [pointer events](https://msdn.microsoft.com/en-us/library/dn433244%28v=vs.85%29.aspx). +var pointer = !!(window.PointerEvent || msPointer); + +// @property touchNative: Boolean +// `true` for all browsers supporting [touch events](https://developer.mozilla.org/docs/Web/API/Touch_events). +// **This does not necessarily mean** that the browser is running in a computer with +// a touchscreen, it only means that the browser is capable of understanding +// touch events. +var touchNative = 'ontouchstart' in window || !!window.TouchEvent; + +// @property touch: Boolean +// `true` for all browsers supporting either [touch](#browser-touch) or [pointer](#browser-pointer) events. +// Note: pointer events will be preferred (if available), and processed for all `touch*` listeners. +var touch = !window.L_NO_TOUCH && (touchNative || pointer); + +// @property mobileOpera: Boolean; `true` for the Opera browser in a mobile device. +var mobileOpera = mobile && opera; + +// @property mobileGecko: Boolean +// `true` for gecko-based browsers running in a mobile device. +var mobileGecko = mobile && gecko; + +// @property retina: Boolean +// `true` for browsers on a high-resolution "retina" screen or on any screen when browser's display zoom is more than 100%. +var retina = (window.devicePixelRatio || (window.screen.deviceXDPI / window.screen.logicalXDPI)) > 1; + +// @property passiveEvents: Boolean +// `true` for browsers that support passive events. +var passiveEvents = (function () { + var supportsPassiveOption = false; + try { + var opts = Object.defineProperty({}, 'passive', { + get: function () { // eslint-disable-line getter-return + supportsPassiveOption = true; + } + }); + window.addEventListener('testPassiveEventSupport', falseFn, opts); + window.removeEventListener('testPassiveEventSupport', falseFn, opts); + } catch (e) { + // Errors can safely be ignored since this is only a browser support test. + } + return supportsPassiveOption; +}()); + +// @property canvas: Boolean +// `true` when the browser supports [``](https://developer.mozilla.org/docs/Web/API/Canvas_API). +var canvas$1 = (function () { + return !!document.createElement('canvas').getContext; +}()); + +// @property svg: Boolean +// `true` when the browser supports [SVG](https://developer.mozilla.org/docs/Web/SVG). +var svg$1 = !!(document.createElementNS && svgCreate('svg').createSVGRect); + +var inlineSvg = !!svg$1 && (function () { + var div = document.createElement('div'); + div.innerHTML = ''; + return (div.firstChild && div.firstChild.namespaceURI) === 'http://www.w3.org/2000/svg'; +})(); + +// @property vml: Boolean +// `true` if the browser supports [VML](https://en.wikipedia.org/wiki/Vector_Markup_Language). +var vml = !svg$1 && (function () { + try { + var div = document.createElement('div'); + div.innerHTML = ''; + + var shape = div.firstChild; + shape.style.behavior = 'url(#default#VML)'; + + return shape && (typeof shape.adj === 'object'); + + } catch (e) { + return false; + } +}()); + + +// @property mac: Boolean; `true` when the browser is running in a Mac platform +var mac = navigator.platform.indexOf('Mac') === 0; + +// @property mac: Boolean; `true` when the browser is running in a Linux platform +var linux = navigator.platform.indexOf('Linux') === 0; + +function userAgentContains(str) { + return navigator.userAgent.toLowerCase().indexOf(str) >= 0; +} + + +var Browser = { + ie: ie, + ielt9: ielt9, + edge: edge, + webkit: webkit, + android: android, + android23: android23, + androidStock: androidStock, + opera: opera, + chrome: chrome, + gecko: gecko, + safari: safari, + phantom: phantom, + opera12: opera12, + win: win, + ie3d: ie3d, + webkit3d: webkit3d, + gecko3d: gecko3d, + any3d: any3d, + mobile: mobile, + mobileWebkit: mobileWebkit, + mobileWebkit3d: mobileWebkit3d, + msPointer: msPointer, + pointer: pointer, + touch: touch, + touchNative: touchNative, + mobileOpera: mobileOpera, + mobileGecko: mobileGecko, + retina: retina, + passiveEvents: passiveEvents, + canvas: canvas$1, + svg: svg$1, + vml: vml, + inlineSvg: inlineSvg, + mac: mac, + linux: linux +}; + +/* + * Extends L.DomEvent to provide touch support for Internet Explorer and Windows-based devices. + */ + +var POINTER_DOWN = Browser.msPointer ? 'MSPointerDown' : 'pointerdown'; +var POINTER_MOVE = Browser.msPointer ? 'MSPointerMove' : 'pointermove'; +var POINTER_UP = Browser.msPointer ? 'MSPointerUp' : 'pointerup'; +var POINTER_CANCEL = Browser.msPointer ? 'MSPointerCancel' : 'pointercancel'; +var pEvent = { + touchstart : POINTER_DOWN, + touchmove : POINTER_MOVE, + touchend : POINTER_UP, + touchcancel : POINTER_CANCEL +}; +var handle = { + touchstart : _onPointerStart, + touchmove : _handlePointer, + touchend : _handlePointer, + touchcancel : _handlePointer +}; +var _pointers = {}; +var _pointerDocListener = false; + +// Provides a touch events wrapper for (ms)pointer events. +// ref https://www.w3.org/TR/pointerevents/ https://www.w3.org/Bugs/Public/show_bug.cgi?id=22890 + +function addPointerListener(obj, type, handler) { + if (type === 'touchstart') { + _addPointerDocListener(); + } + if (!handle[type]) { + console.warn('wrong event specified:', type); + return falseFn; + } + handler = handle[type].bind(this, handler); + obj.addEventListener(pEvent[type], handler, false); + return handler; +} + +function removePointerListener(obj, type, handler) { + if (!pEvent[type]) { + console.warn('wrong event specified:', type); + return; + } + obj.removeEventListener(pEvent[type], handler, false); +} + +function _globalPointerDown(e) { + _pointers[e.pointerId] = e; +} + +function _globalPointerMove(e) { + if (_pointers[e.pointerId]) { + _pointers[e.pointerId] = e; + } +} + +function _globalPointerUp(e) { + delete _pointers[e.pointerId]; +} + +function _addPointerDocListener() { + // need to keep track of what pointers and how many are active to provide e.touches emulation + if (!_pointerDocListener) { + // we listen document as any drags that end by moving the touch off the screen get fired there + document.addEventListener(POINTER_DOWN, _globalPointerDown, true); + document.addEventListener(POINTER_MOVE, _globalPointerMove, true); + document.addEventListener(POINTER_UP, _globalPointerUp, true); + document.addEventListener(POINTER_CANCEL, _globalPointerUp, true); + + _pointerDocListener = true; + } +} + +function _handlePointer(handler, e) { + if (e.pointerType === (e.MSPOINTER_TYPE_MOUSE || 'mouse')) { return; } + + e.touches = []; + for (var i in _pointers) { + e.touches.push(_pointers[i]); + } + e.changedTouches = [e]; + + handler(e); +} + +function _onPointerStart(handler, e) { + // IE10 specific: MsTouch needs preventDefault. See #2000 + if (e.MSPOINTER_TYPE_TOUCH && e.pointerType === e.MSPOINTER_TYPE_TOUCH) { + preventDefault(e); + } + _handlePointer(handler, e); +} + +/* + * Extends the event handling code with double tap support for mobile browsers. + * + * Note: currently most browsers fire native dblclick, with only a few exceptions + * (see https://github.com/Leaflet/Leaflet/issues/7012#issuecomment-595087386) + */ + +function makeDblclick(event) { + // in modern browsers `type` cannot be just overridden: + // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Errors/Getter_only + var newEvent = {}, + prop, i; + for (i in event) { + prop = event[i]; + newEvent[i] = prop && prop.bind ? prop.bind(event) : prop; + } + event = newEvent; + newEvent.type = 'dblclick'; + newEvent.detail = 2; + newEvent.isTrusted = false; + newEvent._simulated = true; // for debug purposes + return newEvent; +} + +var delay = 200; +function addDoubleTapListener(obj, handler) { + // Most browsers handle double tap natively + obj.addEventListener('dblclick', handler); + + // On some platforms the browser doesn't fire native dblclicks for touch events. + // It seems that in all such cases `detail` property of `click` event is always `1`. + // So here we rely on that fact to avoid excessive 'dblclick' simulation when not needed. + var last = 0, + detail; + function simDblclick(e) { + if (e.detail !== 1) { + detail = e.detail; // keep in sync to avoid false dblclick in some cases + return; + } + + if (e.pointerType === 'mouse' || + (e.sourceCapabilities && !e.sourceCapabilities.firesTouchEvents)) { + + return; + } + + // When clicking on an , the browser generates a click on its + //