diff options
Diffstat (limited to 'public/common/client.js')
-rw-r--r-- | public/common/client.js | 1218 |
1 files changed, 1218 insertions, 0 deletions
diff --git a/public/common/client.js b/public/common/client.js new file mode 100644 index 0000000..8aa1f65 --- /dev/null +++ b/public/common/client.js @@ -0,0 +1,1218 @@ +"use strict" + +let params = { + mode: "play", + title_id: window.location.pathname.split("/")[1], + game_id: 0, + role: "Observer", +} + +function init_params() { + let search = new URLSearchParams(window.location.search) + params.game_id = search.get("game") + params.role = search.get("role") || "Observer" + params.mode = search.get("mode") || "play" +} + +init_params() + +let roles = Array.from(document.querySelectorAll(".role")).map(x=>({id:x.id,role:x.id.replace(/^role_/,"").replace(/_/g," ")})) + +let view = null +let player = "Observer" +let socket = null +let chat = null + +let game_log = [] +let game_cookie = 0 + +let snap_active = [] +let snap_cache = [] +let snap_count = 0 +let snap_this = 0 +let snap_view = null + +function scroll_with_middle_mouse(panel_sel, multiplier) { + let panel = document.querySelector(panel_sel) + let down_x, down_y, scroll_x, scroll_y + if (!multiplier) + multiplier = 1 + function md(e) { + if (e.button === 1) { + down_x = e.clientX + down_y = e.clientY + scroll_x = panel.scrollLeft + scroll_y = panel.scrollTop + window.addEventListener("mousemove", mm) + window.addEventListener("mouseup", mu) + e.preventDefault() + } + } + function mm(e) { + let dx = down_x - e.clientX + let dy = down_y - e.clientY + panel.scrollLeft = scroll_x + dx * multiplier + panel.scrollTop = scroll_y + dy * multiplier + e.preventDefault() + } + function mu(e) { + if (e.button === 1) { + window.removeEventListener("mousemove", mm) + window.removeEventListener("mouseup", mu) + e.preventDefault() + } + } + panel.addEventListener("mousedown", md) +} + +function drag_element_with_mouse(element_sel, grabber_sel) { + let element = document.querySelector(element_sel) + let grabber = document.querySelector(grabber_sel) || element + let save_x, save_y + function md(e) { + if (e.button === 0) { + save_x = e.clientX + save_y = e.clientY + window.addEventListener("mousemove", mm) + window.addEventListener("mouseup", mu) + e.preventDefault() + } + } + function mm(e) { + let dx = save_x - e.clientX + let dy = save_y - e.clientY + save_x = e.clientX + save_y = e.clientY + element.style.left = (element.offsetLeft - dx) + "px" + element.style.top = (element.offsetTop - dy) + "px" + e.preventDefault() + } + function mu(e) { + if (e.button === 0) { + window.removeEventListener("mousemove", mm) + window.removeEventListener("mouseup", mu) + e.preventDefault() + } + } + grabber.addEventListener("mousedown", md) +} + +/* TITLE BLINKER */ + +let blink_title = document.title +let blink_timer = 0 + +function start_blinker(message) { + let tick = false + if (blink_timer) + stop_blinker() + if (!document.hasFocus()) { + document.title = message + blink_timer = setInterval(function () { + document.title = tick ? message : blink_title + tick = !tick + }, 1000) + } +} + +function stop_blinker() { + document.title = blink_title + clearInterval(blink_timer) + blink_timer = 0 +} + +window.addEventListener("focus", stop_blinker) + +/* CHAT */ + +function init_chat() { + // only fetch new messages when we reconnect! + if (chat !== null) { + send_message("getchat", chat.log) + return + } + + let chat_window = document.createElement("div") + chat_window.id = "chat_window" + chat_window.innerHTML = ` + <div id="chat_header">Chat</div> + <div id="chat_x" onclick="toggle_chat()">\u274c</div> + <div id="chat_text"></div> + <form id="chat_form" action=""><input id="chat_input" autocomplete="off"></form> + ` + document.querySelector("body").appendChild(chat_window) + + let chat_button = document.getElementById("chat_button") + chat_button.classList.remove("hide") + + chat = { + is_visible: false, + text_element: document.getElementById("chat_text"), + key: "chat/" + params.game_id, + last_day: null, + log: 0 + } + + chat.seen = window.localStorage.getItem(chat.key) | 0 + + drag_element_with_mouse("#chat_window", "#chat_header") + + document.getElementById("chat_form").addEventListener("submit", e => { + let input = document.getElementById("chat_input") + e.preventDefault() + if (input.value) { + send_message("chat", input.value) + input.value = "" + } else { + hide_chat() + } + }) + + document.querySelector("body").addEventListener("keydown", e => { + if (e.key === "Escape") { + if (chat.is_visible) { + e.preventDefault() + hide_chat() + } + } + if (e.key === "Enter") { + let chat_input = document.getElementById("chat_input") + let notepad_input = document.getElementById("notepad_input") + if (document.activeElement !== chat_input && document.activeElement !== notepad_input) { + e.preventDefault() + show_chat() + } + } + }) + + send_message("getchat", 0) +} + +function save_chat() { + window.localStorage.setItem(chat.key, chat.log) +} + +function update_chat(chat_id, raw_date, user, message) { + function format_time(date) { + let mm = date.getMinutes() + let hh = date.getHours() + if (mm < 10) mm = "0" + mm + if (hh < 10) hh = "0" + hh + return hh + ":" + mm + } + function add_date_line(date) { + let line = document.createElement("div") + line.className = "date" + line.textContent = "~ " + date + " ~" + chat.text_element.appendChild(line) + } + function add_chat_line(time, user, message) { + let line = document.createElement("div") + line.textContent = "[" + time + "] " + user + " \xbb " + message + chat.text_element.appendChild(line) + chat.text_element.scrollTop = chat.text_element.scrollHeight + } + if (chat_id > chat.log) { + chat.log = chat_id + let date = new Date(raw_date * 1000) + let day = date.toDateString() + if (day !== chat.last_day) { + add_date_line(day) + chat.last_day = day + } + add_chat_line(format_time(date), user, message) + } + if (chat_id > chat.seen) { + let button = document.getElementById("chat_button") + start_blinker("NEW MESSAGE") + if (!chat.is_visible) + button.classList.add("new") + else + save_chat() + } +} + +function show_chat() { + if (!chat.is_visible) { + document.getElementById("chat_button").classList.remove("new") + document.getElementById("chat_window").classList.add("show") + document.getElementById("chat_input").focus() + chat.is_visible = true + save_chat() + } +} + +function hide_chat() { + if (chat.is_visible) { + document.getElementById("chat_window").classList.remove("show") + document.getElementById("chat_input").blur() + chat.is_visible = false + } +} + +function toggle_chat() { + if (chat.is_visible) + hide_chat() + else + show_chat() +} + +/* NOTEPAD */ + +let notepad = null + +function init_notepad() { + if (notepad !== null) + return + + add_main_menu_item("Notepad", toggle_notepad) + + let notepad_window = document.createElement("div") + notepad_window.id = "notepad_window" + notepad_window.innerHTML = ` + <div id="notepad_header">Notepad: ${player}</div> + <div id="notepad_x" onclick="toggle_notepad()">\u274c</div> + <textarea id="notepad_input" cols="55" rows="10" maxlength="16000" oninput="dirty_notepad()"></textarea> + <div id="notepad_footer"><button id="notepad_save" onclick="save_notepad()" disabled>Save</button></div> + ` + document.querySelector("body").appendChild(notepad_window) + + notepad = { + is_visible: false, + is_dirty: false, + } + + drag_element_with_mouse("#notepad_window", "#notepad_header") +} + +function dirty_notepad() { + if (!notepad.is_dirty) { + notepad.is_dirty = true + document.getElementById("notepad_save").disabled = false + } +} + +function save_notepad() { + if (notepad.is_dirty) { + let text = document.getElementById("notepad_input").value + send_message("putnote", text) + notepad.is_dirty = false + document.getElementById("notepad_save").disabled = true + } +} + +function load_notepad() { + send_message("getnote") +} + +function update_notepad(text) { + document.getElementById("notepad_input").value = text +} + +function show_notepad() { + if (!notepad.is_visible) { + load_notepad() + document.getElementById("notepad_window").classList.add("show") + document.getElementById("notepad_input").focus() + notepad.is_visible = true + } +} + +function hide_notepad() { + if (notepad.is_visible) { + save_notepad() + document.getElementById("notepad_window").classList.remove("show") + document.getElementById("notepad_input").blur() + notepad.is_visible = false + } +} + +function toggle_notepad() { + if (notepad.is_visible) + hide_notepad() + else + show_notepad() +} + +window.addEventListener("keydown", (evt) => { + if (document.activeElement === document.getElementById("chat_input")) + return + if (document.activeElement === document.getElementById("notepad_input")) + return + if (evt.key === "Shift") + document.querySelector("body").classList.add("shift") +}) + +window.addEventListener("keyup", (evt) => { + if (evt.key === "Shift") + document.querySelector("body").classList.remove("shift") +}) + +/* REMATCH BUTTON */ + +function add_icon_button(where, id, img, title, fn) { + let button = document.getElementById(id) + if (!button) { + button = document.createElement("div") + button.id = id + button.title = title + button.className = "icon_button" + button.innerHTML = '<img src="/images/' + img + '.svg">' + button.addEventListener("click", fn) + if (where) + document.getElementById("toolbar").appendChild(button) + else + document.querySelector(".menu").after(button) + } + return button +} + +function remove_resign_menu() { + document.querySelectorAll(".resign").forEach(x => x.remove()) +} + +function goto_rematch() { + window.location = "/rematch/" + params.game_id +} + +function goto_replay() { + let search = new URLSearchParams(window.location.search) + search.delete("role") + search.set("mode", "replay") + window.location.search = search +} + +function on_game_over() { + add_icon_button(1, "replay_button", "sherlock-holmes-mirror", "Watch replay", goto_replay) + if (player !== "Observer") + add_icon_button(1, "rematch_button", "cycle", "Propose a rematch!", goto_rematch) + remove_resign_menu() +} + +/* CONNECT TO GAME SERVER */ + +function init_player_names(players) { + for (let i = 0; i < roles.length; ++i) { + let p = players.find(p => p.role === roles[i].role) + if (p) + document.getElementById(roles[i].id).querySelector(".role_user").innerHTML = `<a href="/user/${p.name}" target="_blank">${p.name}</a>` + else + document.getElementById(roles[i].id).querySelector(".role_user").textContent = "NONE" + } +} + +function send_message(cmd, arg) { + let data = JSON.stringify([ cmd, arg ]) + console.log("SEND %s %s", cmd, arg) + socket.send(data) +} + +let reconnect_count = 0 +let reconnect_max = 10 + +function connect_play() { + if (reconnect_count >= reconnect_max) { + document.title = "DISCONNECTED" + document.getElementById("prompt").textContent = "Disconnected." + return + } + + let protocol = (window.location.protocol === "http:") ? "ws" : "wss" + let seen = document.getElementById("log").children.length + let url = `${protocol}://${window.location.host}/play-socket?title=${params.title_id}&game=${params.game_id}&role=${encodeURIComponent(params.role)}&seen=${seen}` + + console.log("CONNECTING", url) + document.getElementById("prompt").textContent = "Connecting... " + + socket = new WebSocket(url) + + window.addEventListener("beforeunload", function () { + socket.close(1000) + }) + + socket.onopen = function (evt) { + console.log("OPEN") + document.querySelector("header").classList.remove("disconnected") + reconnect_count = 0 + } + + socket.onclose = function (evt) { + console.log("CLOSE %d", evt.code) + game_cookie = 0 + if (evt.code === 1000 && evt.reason !== "") { + document.getElementById("prompt").textContent = "Disconnected: " + evt.reason + document.title = "DISCONNECTED" + } + if (evt.code !== 1000) { + document.querySelector("header").classList.add("disconnected") + document.getElementById("prompt").textContent = `Reconnecting soon... (${reconnect_count+1}/${reconnect_max})` + let wait = 1000 * (Math.random() + 0.5) * Math.pow(2, reconnect_count++) + console.log("WAITING %.1f TO RECONNECT", wait/1000) + setTimeout(connect_play, wait) + } + } + + socket.onmessage = function (evt) { + let msg_data = JSON.parse(evt.data) + let cmd = msg_data[0] + let arg = msg_data[1] + console.log("MESSAGE", cmd) + switch (cmd) { + case "warning": + document.getElementById("prompt").textContent = arg + document.querySelector("header").classList.add("disconnected") + setTimeout(() => { + document.querySelector("header").classList.remove("disconnected") + on_update_header() + }, 1000) + break + + case "error": + document.getElementById("prompt").textContent = arg + if (view) { + view.actions = null + on_update() + } + break + + case "chat": + update_chat(arg[0], arg[1], arg[2], arg[3]) + break + + case "note": + update_notepad(arg) + break + + case "players": + player = arg[0] + document.querySelector("body").classList.add(player.replace(/ /g, "_")) + if (player !== "Observer") { + init_chat() + init_notepad() + } else { + remove_resign_menu() + } + init_player_names(arg[1]) + break + + case "presence": + { + let list = Array.isArray(arg) ? arg : Object.keys(arg) + for (let i = 0; i < roles.length; ++i) { + let elt = document.getElementById(roles[i].id) + elt.classList.toggle("present", list.includes(roles[i].role)) + } + } + break + + case "state": + game_cookie = msg_data[2] + + if (snap_view) + on_snap_stop() + + view = arg + + game_log.length = view.log_start + for (let line of view.log) + game_log.push(line) + + on_update_header() + if (typeof on_update === "function") + on_update() + on_update_log(view.log_start, game_log.length) + if (view.game_over) + on_game_over() + break + + case "snapsize": + snap_count = arg + if (snap_count === 0) + replay_panel.remove() + else + document.querySelector("body").appendChild(replay_panel) + console.log("SNAPSIZE", snap_count) + break + + case "snap": + console.log("SNAP", arg[0]) + snap_active[arg[0]] = arg[1] + snap_cache[arg[0]] = arg[2] + show_snap(arg[0]) + break + + case "reply": + if (typeof on_reply === "function") + on_reply(arg[0], arg[1]) + break + + case "save": + window.localStorage[params.title_id + "/save"] = arg + break + } + } +} + +/* HEADER */ + +let is_your_turn = false +let old_active = null + +function on_update_header() { + document.getElementById("prompt").textContent = view.prompt + if (params.mode === "replay") + return + if (snap_view) + document.querySelector("header").classList.add("replay") + else + document.querySelector("header").classList.remove("replay") + if (view.actions) { + document.querySelector("header").classList.add("your_turn") + if (!is_your_turn || old_active !== view.active) + start_blinker("YOUR TURN") + is_your_turn = true + } else { + document.querySelector("header").classList.remove("your_turn") + is_your_turn = false + } + old_active = view.active +} + +/* LOG */ + +function on_update_log(change_start, end) { + let div = document.getElementById("log") + + let to_delete = div.children.length - change_start + while (to_delete-- > 0) + div.removeChild(div.lastChild) + + for (let i = div.children.length; i < end; ++i) { + let text = game_log[i] + if (params.mode === "debug" && typeof text === "object") { + let entry = document.createElement("a") + entry.href = "#" + text[0] + if (text[3] !== null) + entry.textContent = "\u25b6 " + text[1] + " " + text[2] + " " + text[3] + else + entry.textContent = "\u25b6 " + text[1] + " " + text[2] + entry.style.display = "block" + entry.style.textDecoration = "none" + div.appendChild(entry) + } else if (typeof on_log === "function") { + div.appendChild(on_log(text)) + } else { + let entry = document.createElement("div") + entry.textContent = text + div.appendChild(entry) + } + } + scroll_log_to_end() +} + +function scroll_log_to_end() { + let div = document.getElementById("log") + div.scrollTop = div.scrollHeight +} + +try { + new ResizeObserver(scroll_log_to_end).observe(document.getElementById("log")) +} catch (err) { + window.addEventListener("resize", scroll_log_to_end) +} + +/* ACTIONS */ + +function action_button_imp(action, label, callback) { + if (params.mode === "replay") + return + let id = action + "_button" + let button = document.getElementById(id) + if (!button) { + button = document.createElement("button") + button.id = id + button.textContent = label + button.addEventListener("click", callback) + document.getElementById("actions").appendChild(button) + } + if (view.actions && action in view.actions) { + button.classList.remove("hide") + if (view.actions[action]) { + if (label === undefined) + button.textContent = view.actions[action] + button.disabled = false + } else { + button.disabled = true + } + } else { + button.classList.add("hide") + } +} + +function action_button(action, label) { + action_button_imp(action, label, evt => send_action(action)) +} + +function confirm_action_button(action, label, message) { + action_button_imp(action, label, evt => confirm_action(message, action)) +} + +function send_action(verb, noun) { + if (params.mode === "replay" || params.mode === "debug") + return false + // Reset action list here so we don't send more than one action per server prompt! + if (noun !== undefined) { + let realnoun = Array.isArray(noun) ? noun[0] : noun + if (view.actions && view.actions[verb] && view.actions[verb].includes(realnoun)) { + view.actions = null + send_message("action", [ verb, noun, game_cookie ]) + return true + } + } else { + if (view.actions && view.actions[verb]) { + view.actions = null + send_message("action", [ verb, null, game_cookie ]) + return true + } + } + return false +} + +function confirm_action(message, verb, noun) { + if (window.confirm(message)) + send_action(verb, noun) +} + +function send_query(q, param) { + if (typeof replay_query === "function") + replay_query(q, param) + else if (snap_view) + send_message("querysnap", [ snap_this, q, param ]) + else + send_message("query", [ q, param ]) +} + +function confirm_resign() { + if (window.confirm("Are you sure that you want to resign?")) + send_message("resign") +} + +function send_save() { + send_message("save") +} + +function send_restore() { + send_message("restore", window.localStorage[params.title_id + "/save"]) +} + +/* REPLAY */ + +function init_replay() { + let script = document.createElement("script") + script.src = "/common/replay.js" + document.body.appendChild(script) +} + +window.addEventListener("load", function () { + zoom_map() + if (params.mode === "debug") + init_replay() + else if (params.mode === "replay") + init_replay() + else if (params.mode === "play") + connect_play() + else + document.getElementById("prompt").textContent = "Invalid mode: " + params.mode +}) + +/* MAIN MENU */ + +add_icon_button(0, "chat_button", "chat-bubble", "Open chat", toggle_chat).classList.add("hide") +add_icon_button(0, "zoom_button", "magnifying-glass", "Zoom", () => toggle_zoom()) +add_icon_button(0, "log_button", "scroll-quill", "Hide log", toggle_log) +add_icon_button(0, "fullscreen_button", "expand", "Fullscreen", toggle_fullscreen) + +function init_main_menu() { + let popup = document.querySelector(".menu_popup") + let sep = document.createElement("div") + sep.className = "menu_separator" + sep.id = "main_menu_separator" + popup.insertBefore(sep, popup.firstChild) +} + +function add_main_menu_item(text, onclick) { + let popup = document.querySelector(".menu_popup") + let sep = document.getElementById("main_menu_separator") + let item = document.createElement("div") + item.className = "menu_item" + item.onclick = onclick + item.textContent = text + popup.insertBefore(item, sep) +} + +function add_main_menu_item_link(text, url) { + let popup = document.querySelector(".menu_popup") + let sep = document.getElementById("main_menu_separator") + let item = document.createElement("a") + item.className = "menu_item" + item.href = url + item.textContent = text + popup.insertBefore(item, sep) +} + +init_main_menu() +if (params.mode === "play" && params.role !== "Observer") { + add_main_menu_item_link("Go home", "/games/active") + add_main_menu_item_link("Go to next game", "/games/next") +} else { + add_main_menu_item_link("Go home", "/") +} + +function toggle_fullscreen() { + if (document.fullscreenElement) + document.exitFullscreen() + else + document.documentElement.requestFullscreen() +} + +/* SNAPSHOT VIEW */ + +var replay_panel = null + +function add_replay_button(parent, id, callback) { + let button = document.createElement("div") + button.className = "replay_button" + button.id = id + button.onclick = callback + parent.appendChild(button) + return button +} + +function init_snap() { + replay_panel = document.createElement("div") + replay_panel.id = "replay_panel" + add_replay_button(replay_panel, "replay_first", on_snap_first) + add_replay_button(replay_panel, "replay_prev", on_snap_prev) + add_replay_button(replay_panel, "replay_step_prev", null).classList.add("hide") + add_replay_button(replay_panel, "replay_step_next", null).classList.add("hide") + add_replay_button(replay_panel, "replay_next", on_snap_next) + add_replay_button(replay_panel, "replay_last", null).classList.add("hide") + add_replay_button(replay_panel, "replay_play", on_snap_stop) + add_replay_button(replay_panel, "replay_stop", null).classList.add("hide") +} + +init_snap() + +function request_snap(snap_id) { + if (snap_id >= 1 && snap_id <= snap_count) { + snap_this = snap_id + if (snap_cache[snap_id]) + show_snap(snap_id) + else + send_message("getsnap", snap_id) + } +} + +function show_snap(snap_id) { + if (snap_view === null) + snap_view = view + view = snap_cache[snap_id] + view.prompt = "Replay " + snap_id + " / " + snap_count + " \u2013 " + snap_active[snap_id] + on_update_header() + on_update() + on_update_log(view.log, view.log) +} + +function on_snap_first() { + request_snap(1) +} + +function on_snap_prev() { + if (!snap_view) + request_snap(snap_count) + else if (snap_this > 1) + request_snap(snap_this - 1) +} + +function on_snap_next() { + if (!snap_view) + on_snap_stop() + else if (snap_this < snap_count) + request_snap(snap_this + 1) + else + on_snap_stop() +} + +function on_snap_stop() { + if (snap_view) { + view = snap_view + snap_view = null + on_update_header() + on_update() + on_update_log(game_log.length, game_log.length) + } +} + +/* TOGGLE ZOOM MAP TO FIT */ + +function toggle_log() { + document.querySelector("aside").classList.toggle("hide") + zoom_map() +} + +var toggle_zoom = function () {} + +function zoom_map() { + let mapwrap = document.getElementById("mapwrap") + if (mapwrap) { + let main = document.querySelector("main") + let map = document.getElementById("map") + map.style.transform = null + mapwrap.style.width = null + mapwrap.style.height = null + if (mapwrap.classList.contains("fit")) { + let { width: gw, height: gh } = main.getBoundingClientRect() + let { width: ww, height: wh } = mapwrap.getBoundingClientRect() + let { width: cw, height: ch } = map.getBoundingClientRect() + let scale = Math.min(ww / cw, gh / ch) + if (scale < 1) { + map.style.transform = "scale(" + scale + ")" + mapwrap.style.width = (cw * scale) + "px" + mapwrap.style.height = (ch * scale) + "px" + } + } + } +} + +window.addEventListener("resize", zoom_map) + +/* PAN & ZOOM GAME BOARD */ + +;(function panzoom_init() { + const MIN_ZOOM = 0.5 + const MAX_ZOOM = 1.5 + const THRESHOLD = 0.0625 + const DECELERATION = 125 + + console.log("DPX", window.devicePixelRatio) + + const e_scroll = document.querySelector("main") + e_scroll.style.touchAction = "none" + + const e_inner = document.createElement("div") + e_inner.id = "pan_zoom_main" + e_inner.style.transformOrigin = "0 0" + e_inner.style.height = "120px" + while (e_scroll.firstChild) + e_inner.appendChild(e_scroll.firstChild) + + const e_outer = document.createElement("div") + e_outer.id = "pan_zoom_wrap" + e_outer.style.height = "120px" + e_outer.appendChild(e_inner) + + e_scroll.appendChild(e_outer) + + const mapwrap = document.getElementById("mapwrap") + const map = document.getElementById("map") || e_inner.firstChild + const map_w = mapwrap ? mapwrap.clientWidth : map.clientWidth + const map_h = mapwrap ? mapwrap.clientHeight : map.clientHeight + + console.log("MAP", map_w, map_h) + + var transform0 = { x: 0, y: 0, scale: 1 } + var transform1 = { x: 0, y: 0, scale: 1 } + var old_scale = 1 + + // touch finger tracking + var last_touch_x = {} + var last_touch_y = {} + var last_touch_length = 0 + + // momentum velocity tracking + var mom_last_t = null + var mom_last_x = null + var mom_last_y = null + + // momentum auto-scroll + var timer = 0 + var mom_time = 0 + var mom_vx = 0 + var mom_vy = 0 + + try { + new ResizeObserver(update_transform_resize).observe(document.getElementById("log")) + } catch (err) { + window.addEventListener("resize", function (evt) { + old_scale = 0 + update_transform() + }) + } + + function clamp_scale(scale) { + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + if (scale * transform0.scale > MAX_ZOOM) + scale = MAX_ZOOM / transform0.scale + if (scale * transform0.scale < real_min_zoom) + scale = real_min_zoom / transform0.scale + return scale + } + + function anchor_transform(touches) { + // in case it changed from outside + transform1.x = -e_scroll.scrollLeft + transform1.y = -e_scroll.scrollTop + + transform0.scale = transform1.scale + transform0.x = transform1.x + transform0.y = transform1.y + if (touches) { + for (let touch of touches) { + last_touch_x[touch.identifier] = touch.clientX + last_touch_y[touch.identifier] = touch.clientY + } + last_touch_length = touches.length + } else { + last_touch_length = 0 + } + } + + function toggle_zoom_imp() { + if (transform1.scale === 1) { + if (window.innerWidth >= 800) { + if (mapwrap) { + mapwrap.classList.toggle("fit") + zoom_map() + return + } + } + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + let min_z = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + zoom_to(min_z) + } else { + zoom_to(1) + } + return false + } + + function disable_map_fit() { + if (mapwrap && mapwrap.classList.contains("fit")) { + mapwrap.classList.remove("fit") + zoom_map() + } + } + + function zoom_to(new_scale) { + let cx = e_scroll.clientWidth / 2 + let cy = 0 + + // in case changed from outside + transform1.x = -e_scroll.scrollLeft + transform1.y = -e_scroll.scrollTop + + transform1.x -= cx + transform1.y -= cy + transform1.x *= new_scale / transform1.scale + transform1.y *= new_scale / transform1.scale + transform1.scale = new_scale + transform1.x += cx + transform1.y += cy + + update_transform() + } + + function update_transform() { + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + + // clamp zoom + let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + transform1.scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale)) + + e_scroll.scrollLeft = -transform1.x + e_scroll.scrollTop = -transform1.y + + if (transform1.scale !== old_scale) { + if (transform1.scale === 1) { + e_inner.style.transform = null + } else { + e_inner.style.transform = `scale(${transform1.scale})` + disable_map_fit() + } + e_inner.style.width = (win_w / transform1.scale) + "px" + e_outer.style.width = (e_inner.clientWidth * transform1.scale) + "px" + old_scale = transform1.scale + } + } + + function start_measure(time) { + mom_last_t = [ time, time, time ] + mom_last_x = [ transform1.x, transform1.x, transform1.x ] + mom_last_y = [ transform1.y, transform1.y, transform1.y ] + } + + function abort_measure(time) { + mom_last_t = mom_last_x = mom_last_y = null + } + + function move_measure(time) { + if (mom_last_t) { + mom_last_t[0] = time + mom_last_x[0] = transform1.x + mom_last_y[0] = transform1.y + if (mom_last_t[0] - mom_last_t[1] > 15) { + mom_last_t[2] = mom_last_t[1] + mom_last_x[2] = mom_last_x[1] + mom_last_y[2] = mom_last_y[1] + mom_last_t[1] = mom_last_t[0] + mom_last_x[1] = mom_last_x[0] + mom_last_y[1] = mom_last_y[0] + } + } + } + + function start_momentum() { + if (mom_last_t) { + let dt = mom_last_t[0] - mom_last_t[2] + if (dt > 5) { + mom_time = Date.now() + mom_vx = (mom_last_x[0] - mom_last_x[2]) / dt + mom_vy = (mom_last_y[0] - mom_last_y[2]) / dt + if (Math.hypot(mom_vx, mom_vy) < THRESHOLD) + mom_vx = mom_vy = 0 + if (mom_vx || mom_vy) + timer = requestAnimationFrame(update_momentum) + } + } + } + + function stop_momentum() { + cancelAnimationFrame(timer) + timer = 0 + } + + function update_momentum() { + var now = Date.now() + var dt = now - mom_time + mom_time = now + + transform1.x = transform1.x + mom_vx * dt + transform1.y = transform1.y + mom_vy * dt + update_transform() + + var decay = Math.pow(0.5, dt / DECELERATION) + mom_vx *= decay + mom_vy *= decay + + if (Math.hypot(mom_vx, mom_vy) < THRESHOLD) + mom_vx = mom_vy = 0 + + if (mom_vx || mom_vy) + timer = requestAnimationFrame(update_momentum) + } + + e_scroll.ontouchstart = function (evt) { + anchor_transform(evt.touches) + stop_momentum() + start_measure(evt.timeStamp) + } + + e_scroll.ontouchend = function (evt) { + anchor_transform(evt.touches) + if (evt.touches.length === 0) + start_momentum() + } + + e_scroll.ontouchmove = function (evt) { + if (evt.touches.length !== last_touch_length) + anchor_transform(evt.touches) + + if (evt.touches.length === 1 || evt.touches.length === 2) { + let a = evt.touches[0] + + let dx = a.clientX - last_touch_x[a.identifier] + let dy = a.clientY - last_touch_y[a.identifier] + + transform1.scale = transform0.scale + transform1.x = transform0.x + dx + transform1.y = transform0.y + dy + + if (evt.touches.length === 1) + move_measure(evt.timeStamp) + else + abort_measure() + + // zoom + if (evt.touches.length === 2) { + let b = evt.touches[1] + + let old_x = last_touch_x[a.identifier] - last_touch_x[b.identifier] + let old_y = last_touch_y[a.identifier] - last_touch_y[b.identifier] + let old = Math.sqrt(old_x * old_x + old_y * old_y) + + let cur_x = a.clientX - b.clientX + let cur_y = a.clientY - b.clientY + let cur = Math.sqrt(cur_x * cur_x + cur_y * cur_y) + + let scale = clamp_scale(cur / old) + + let cx = a.clientX + let cy = a.clientY + + transform1.x -= cx + transform1.y -= cy + + transform1.scale *= scale + transform1.x *= scale + transform1.y *= scale + + transform1.x += cx + transform1.y += cy + } + + update_transform() + } + } + + e_scroll.addEventListener( + "wheel", + function (evt) { + if (evt.ctrlKey) { + anchor_transform(null) + + let win_w = e_scroll.clientWidth + let win_h = e_scroll.clientHeight + let real_min_zoom = Math.min(MIN_ZOOM, win_w / map_w, win_h / map_h) + + // one "click" of 120 units -> 10% change + let new_scale = Math.max(real_min_zoom, Math.min(MAX_ZOOM, transform1.scale + event.wheelDeltaY / 1200)) + + // snap to 1 if close + console.log("WHEEL ", new_scale, Math.abs(event.wheelDeltaY / 2400)) + if (Math.abs(1 - new_scale) < Math.abs(event.wheelDeltaY / 2400)) { + console.log("SNAP TO 1 ", Math.abs(event.wheelDeltaY / 2400)) + new_scale = 1 + } + + transform1.x -= event.clientX + transform1.y -= event.clientY + + transform1.x *= new_scale / transform1.scale + transform1.y *= new_scale / transform1.scale + transform1.scale = new_scale + + transform1.x += event.clientX + transform1.y += event.clientY + + update_transform() + evt.preventDefault() + } + }, + { passive: false } + ) + + toggle_zoom = toggle_zoom_imp +})() |