"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 = `
Chat
\u274c
` 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 = `
Notepad: ${player}
\u274c
` 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 = '' 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 = `${p.name}` 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 })()