您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
Modifies the Connect workout page so running workouts use speed targets for display and configuration, in addition to pace targets
// ==UserScript== // @name Garmin Connect: Use speed targets in running workouts // @namespace http://tampermonkey.net/ // @description Modifies the Connect workout page so running workouts use speed targets for display and configuration, in addition to pace targets // @author flowstate // @match https://connect.garmin.com/modern/* // @icon https://www.google.com/s2/favicons?sz=64&domain=garmin.com // @grant window.onurlchange // @license MIT // @version 0.7 // ==/UserScript== (function () { 'use strict'; let alreadyHere = false let tasks = []; oneTimeInit(); function oneTimeInit() { waitForUrl(); } function waitForUrl() { // if (window.onurlchange == null) { // feature is supported window.addEventListener('urlchange', onUrlChange); // } onUrlChange(); } function onUrlChange() { const urlMatches = window.location.href.startsWith('https://connect.garmin.com/modern/workout/'); if (!alreadyHere) { if (urlMatches) { alreadyHere = true; init(); } } else { if (!urlMatches) { alreadyHere = false; deinit(); } } } function init() { tasks = []; tasks.push(waitForElement(`.workoutPage`, waitForChanges)); } function deinit() { tasks.forEach(task => task.stop()); tasks = []; observer && observer.disconnect() observer = null } // function addStyle(styleString) { // const style = document.createElement('style'); // style.textContent = styleString; // document.head.append(style); // } function waitForElement(readySelector, callback) { let timer = undefined; const tryNow = function () { const elem = document.querySelector(readySelector); if (elem) { callback(elem); } else { timer = setTimeout(tryNow, 300); } }; const stop = function () { clearTimeout(timer); timer = undefined; } tryNow(); return { stop } } let observer function waitForChanges(element) { const config = { childList: true, subtree: true }; // Callback function to execute when mutations are observed const callback = (mutationList, observer) => { onElementChanged() }; // Create an observer instance linked to the callback function observer = new MutationObserver(callback); // Start observing the target node for configured mutations observer.observe(element, config); } function onElementChanged() { const stepDataFields = document.querySelectorAll("[class*=StepDataField_data]") for (const field of stepDataFields) { if (!field.getAttribute(speedModAttr)) { modifyStepDataField(field) } } const paceInputField = document.getElementById("target-pace-to") if (paceInputField) { modifyPaceInputField(paceInputField); } } function escapeRegExp(str) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); // $& means the whole matched string } function replaceAll(str, find, replace) { return str.replace(new RegExp(escapeRegExp(find), 'g'), replace); } const speedModAttr = "_speed-mod" function modifyStepDataField(element) { let innerText = element.innerText let metric = true // TODO fix this so it works for language which don't use "km"/"mi" if (innerText.includes("/km") || innerText.includes("/mi")) { if (innerText.includes("/mi")) { metric = false innerText = replaceAll(innerText, "mi", "km") } const exp = /(\d*:\d*) *\/km(\s)*\((\d*:\d*)-(\d*:\d*) *\/km\)/ const matches = innerText.match(exp) if (matches.length < 5) { return } const target = matches[1] const targetLow = matches[3] const targetHigh = matches[4] const unit = metric ? "/km" : "/mi" // TODO see if localized strings can be used // (but Garmin uses "kph" which is frowned upon / nonstandard) const speedUnit = metric ? "km/h" : "mph" const newText = `${target} ${unit} (${targetLow} - ${targetHigh} ${unit}) ${paceToSpeed(target)} ${speedUnit} (${paceToSpeed(targetHigh)} - ${paceToSpeed(targetLow)} ${speedUnit})` element.innerText = newText element.setAttribute(speedModAttr, true) } } const paceModAttr = "_pace-mod" function modifyPaceInputField(element) { if (element.getAttribute(paceModAttr)) { return } element.setAttribute(paceModAttr, true) const wrapper = element.closest("[class*=RangeInput_content]"); const clone = wrapper.cloneNode(true) const label = clone.querySelector("label") if (label) { label.innerText = "Speed" } let metric = true const originalUnitsLabel = wrapper.querySelector("[class*=TimeDurationInput_appendedLabel]") if (!originalUnitsLabel.innerText.includes("km")) { metric = false } const unitsLabel = clone.querySelector("[class*=TimeDurationInput_appendedLabel]") if (unitsLabel) { unitsLabel.innerText = metric ? "km/h" : "mph" } let minsFields = clone.querySelectorAll("[class*=TimeDurationInput_minutes]") for (const field of minsFields) { let html = field.parentNode.innerHTML html = replaceAll(html, ":", "") field.parentNode.innerHTML = html } minsFields = clone.querySelectorAll("[class*=TimeDurationInput_minutes]") for (const field of minsFields) { field.remove() } const secsFields = clone.querySelectorAll("[class*=TimeDurationInput_seconds]") // reverse order const fieldIds = ["_speed-mod-sec-max", "_speed-mod-sec-min"] // reverse order const paceFields = ["target-pace-input", "target-pace-to"] for (const field of secsFields) { field.style.width = "64px" field.setAttribute("placeholder", "") field.setAttribute("style", ` // background-color: inherit !important; caret-color: inherit !important; color: #555 !important; text-align: left !important; box-shadow: none !important; border: none !important; width: 64px !important; -webkit-user-select: text !important; -khtml-user-select: text !important; -moz-user-select: text !important; -o-user-select: text !important; user-select: text !important; `) field.parentNode.setAttribute("style", ` -webkit-user-select: text !important; -khtml-user-select: text !important; -moz-user-select: text !important; -o-user-select: text !important; user-select: text !important; ` ) field.id = fieldIds.pop() linkSpeedAndPaceField(field, paceFields.pop()) if (fieldIds.length === 0) { break } } clone.style.marginTop = "-8px" const parent = wrapper.parentNode parent.appendChild(clone) } function createOnPaceFieldChanged(minsField, secsField, speedField) { return (function () { const min = minsField.value const sec = secsField.value const speed = paceToSpeed(`${min}:${sec}`) speedField.value = speed }) } function createSpeedFieldChanged(minsField, secsField, speedField) { return (function () { try { let { min, sec } = speedToPace(speedField.value) if (min === 0 && sec === 0) { sec = 1 } if (min > 99) { min = 99 sec = 59 } minsField.value = min minsField.focus() // update internal component as if user typed value secsField.value = sec.toString().padStart(2, "0") secsField.focus() speedField.focus() } catch (e) { console.log(e) } }) } function linkSpeedAndPaceField(speedField, paceId) { const paceFieldMins = document.getElementById(paceId) const paceFieldSecs = paceFieldMins.parentNode.querySelector("[class*=TimeDurationInput_seconds") const paceHandler = createOnPaceFieldChanged(paceFieldMins, paceFieldSecs, speedField) paceFieldMins.addEventListener("change", paceHandler) paceFieldMins.addEventListener("input", paceHandler) paceFieldMins.addEventListener("keydown", paceHandler) paceFieldSecs.addEventListener("change", paceHandler) paceFieldSecs.addEventListener("input", paceHandler) paceFieldSecs.addEventListener("keydown", paceHandler) paceHandler() const speedHandler = createSpeedFieldChanged(paceFieldMins, paceFieldSecs, speedField) speedField.addEventListener("change", speedHandler) // Can't update pace field immediately because the lower and upper range // are instantly validated against each other, so typing "1" for the upper speed (for example) // will instantly set a lower pace of "60:00" which will cause the upper pace to increase // to "60:00" // // speedField.addEventListener("input", speedHandler) // speedField.addEventListener("keydown", speedHandler) } // "5:00" => "12.00" function paceToSpeed(str) { const components = str.split(":") const min = parseInt(components[0], 10) const seconds = parseInt(components[1], 10) const secondsPerUnitDistance = min * 60 + seconds const unitDistancePerSecond = 1 / secondsPerUnitDistance const unitDistancePerHour = unitDistancePerSecond * 3600 return (Math.round(unitDistancePerHour * 100) / 100).toFixed(2) } // "12.00" => { min: 5, sec: 0 } function speedToPace(str) { const speed = parseFloat(str) if (isNaN(speed)) { console.log("invalid speed " + str) throw Error("invalid speed") } const unitDistancePerSecond = speed / 3600 const secondsPerUnitDistance = Math.round(1 / unitDistancePerSecond) const min = Math.floor(secondsPerUnitDistance / 60) const sec = secondsPerUnitDistance % 60 return { min, sec, } } })();