您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
A userscript for Strava that adds additional stats and features to the starred segments page.
// ==UserScript== // @name Better Segments for Strava // @namespace https://github.com/jerboa88 // @version 0.2.0 // @author John Goodliff // @description A userscript for Strava that adds additional stats and features to the starred segments page. // @license MIT // @icon data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAMAAACdt4HsAAAA51BMVEUAAAD8TAL8TAL8TAIAAAD8TAL39/fg4OD8TAIAAAAAAAD8TAL8TAIAAAD8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TALl5eX8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL5+fn4+Pj8TAL8TAL8TALWQAL8TAL8TAL8TAL8TAL8TAL8TAL8TAL///8AuADjRAKq2Kqk1qTM58zKysqnpqZ8fHz39/ft7e3n5+fi8eLV1dXFxcWxsbHj4eHX19eampqZmZlUgKVHAAAAOHRSTlMAZJ6ZBwmnhfYYDOAeGigL8+LXsD8t+NzRq6iHfldKOv3vyr+5kHRvXkQwEaSkjYF4cRa4lFMgHLx0l9gAAAHDSURBVFjD7ZVnW8IwEIBRrBaK0kLZG9koAq6LqOzh+P+/x3BEbEkHtvLFp+8XknD3JiSX4PM4PBcxd/lCJBF2JUgDRN3k14FSc55fiAOlWHCa35YAUfoOBRVgXDrLv4EtGbtYVeDHmqAhb3DCqqbjj3D1EiphZjSFH3KIq7DksVYAYnfHX8ZEKSwksFHeWWNNBL2A7pRukiqmiS06lYhNv255FQBOAHLuZyAHSGOnzbgvASdAUm3s4ayakLvNar63KZwGMBGA0mRXCHtJQbcf7FqpEpgIkM46aTOF/LB7ImtZBsBSAPRA69zZ5wGp0cMDOwGI1TirPq4qix0RLAQKfmtS/xXQEJcMBccxzfqUR72gL2l+ZctvLNDsUFE1fBuQquAzE9AbxCbqGr1OiEL31kKAVWLyDqZYrVkIkJwMkuFLLCRAbvDx/EAo2jL5j7gN+awF9ngCT3BgwS/xBLygcOQA1fePCF6dItdBh/EBMn+lzEnvaC96hEymkykhga3g+YnyQgawFwMyWa4Wn1Ny7liwWC0/3kcuBGQ4ehsT5wKaPxzPxu5WMNOtAGEDtvDxwewZkrWrA9P4E4ZdJhfv8Td8Aar4+B4sHPOcAAAAAElFTkSuQmCC // @homepage https://johng.io/p/better-segments-for-strava // @homepageURL https://johng.io/p/better-segments-for-strava // @source https://github.com/jerboa88/better-segments-for-strava.git // @supportURL https://github.com/jerboa88/better-segments-for-strava/issues // @match https://www.strava.com/athlete/segments/starred* // @grant none // ==/UserScript== (function () { 'use strict'; const PROJECT = { EMOJI: "🏁", NAME: "Better Segments" }; const SCRIPT_NAME = `${PROJECT.EMOJI} ${PROJECT.NAME}`; const buildLogPrefix = /* @__PURE__ */ (() => { const colorMap = { primary: "#fc5200", // Strava orange debug: "#009966", // TW 4 Emerald 600 info: "#4f39f6", // TW 4 Indigo 600 warn: "#fc5200", // Strava orange error: "#e7000b" // TW 4 Red 600 }; return (severity) => [ `%c${SCRIPT_NAME} %c${severity}`, `font-style:italic;color:${colorMap.primary};`, `color:${colorMap[severity]};` ]; })(); const buildLogFn = (severity) => { const logFn = console[severity]; const logPrefix = buildLogPrefix(severity); return (...args) => logFn(...logPrefix, ...args); }; const debug = buildLogFn("debug"); buildLogFn("info"); buildLogFn("warn"); buildLogFn("error"); const pattern = /^\/athlete\/segments\/starred$/; const handler = () => { debug("Setting up starred segments page"); const MSG = { rowNum: "#", recordDiffSeconds: "PR Diff Time", recordDiffPercent: "PR Diff %", gender: "Compare with Gender", itemsPerPage: "Items per Page", genderAny: "Any", genderMen: "Men", genderWomen: "Women", kilometersSuffix: "km", milesSuffix: "mi", feetSuffix: "ft", percentSuffix: "%", secondsSuffix: "s", plusPrefix: "+", minusPrefix: "-", placeholderValue: "—" }; const SORT_INDICATOR = { 1: " ▲", "-1": " ▼" }; const ITEMS_PER_PAGE = [20, 50, 100, 200, 500, 1e3, 2e3]; const GENDER = { any: MSG.genderAny, men: MSG.genderMen, women: MSG.genderWomen }; const QUERY_PARAM = { itemsPerPage: "per_page", pageNum: "page" }; const COL_INDEX = { rowNum: 0, star: 1, sport: 2, name: 3, category: 4, distance: 5, elevationDiff: 6, avgGrade: 7, menRecord: 8, womenRecord: 9, myRecord: 10, myGoal: 11, recordDiffSeconds: 12, recordDiffPercentage: 13 }; const COLS = [ { parseFn: Number.parseInt, title: MSG.rowNum }, {}, { parseFn: parseText }, { parseFn: parseText }, {}, { parseFn: parseDistance }, { parseFn: parseDistance }, { parseFn: parsePercentage }, { parseFn: parseDuration }, { parseFn: parseDuration }, { parseFn: parseDuration }, { parseFn: parseDuration }, { parseFn: parseDuration, title: MSG.recordDiffSeconds }, { parseFn: parsePercentage, title: MSG.recordDiffPercent } ]; const DEFAULT = { itemsPerPage: ITEMS_PER_PAGE[0], gender: GENDER.any, sortColIndex: COL_INDEX.rowNum, sortDirection: 1 }; const sortIndicatorRegex = RegExp( ` [${Object.values(SORT_INDICATOR).join("")}]$` ); const state = { gender: DEFAULT.gender, sort: { colIndex: DEFAULT.sortColIndex, direction: DEFAULT.sortDirection } }; const [table, headerRow, rows] = buildTable(); updateTable(); sortTableByCol(0); function assertExists(value, msg) { if (value === null || value === void 0) throw new Error(msg); return value; } function formatTimeDiff(secondsDiff) { const absSecondsDiff = Math.abs(secondsDiff); if (absSecondsDiff < 60) return `${absSecondsDiff}${MSG.secondsSuffix}`; const minutes = Math.floor(absSecondsDiff / 60); const secondsString = String(absSecondsDiff % 60).padStart(2, "0"); return `${minutes}:${secondsString}`; } function getSignFor(number) { return number < 0 ? MSG.minusPrefix : number > 0 ? MSG.plusPrefix : ""; } function getQueryParam(key) { return new URLSearchParams(window.location.search).get(key); } function getColorFor(value) { const lightness = 0.7; const chroma = 0.15; const hue = 150 - Math.sign(value) * Math.abs(value) ** (1 / 5) * 30; return `oklch(${lightness} ${chroma} ${hue})`; } function parseDuration(durationString) { if (durationString === MSG.placeholderValue) return null; if (durationString.includes(":")) { const [min, sec] = durationString.split(":").map(Number); return min * 60 + sec; } if (durationString.endsWith("s")) { return Number.parseInt(durationString); } return Number.parseFloat(durationString); } function parsePercentage(percentageString) { if (percentageString === MSG.placeholderValue) return null; return Number.parseFloat(percentageString); } function parseDistance(distanceString) { const distanceValue = Number.parseFloat(distanceString); if (distanceString.endsWith(MSG.kilometersSuffix)) return distanceValue * 1e3; if (distanceString.endsWith(MSG.milesSuffix)) return distanceValue * 1609.344; if (distanceString.endsWith(MSG.feetSuffix)) return distanceValue * 0.3048; return distanceValue; } function parseText(textString) { return textString.toLowerCase(); } function createCol(colIndex) { const colHeader = document.createElement("th"); colHeader.textContent = COLS[colIndex].title; colHeader.style.cursor = "pointer"; return colHeader; } function createSelector(title, optionNames, defaultOptionName, onChange) { const fragment = document.createDocumentFragment(); const wrapperDiv = document.createElement("div"); wrapperDiv.className = "edit-js editable-setting editing inset"; wrapperDiv.style.background = "#f7f7fa"; wrapperDiv.style.borderTop = "1px solid #f0f0f5"; wrapperDiv.style.borderBottom = "1px solid #f0f0f5"; wrapperDiv.style.marginTop = "20px"; wrapperDiv.style.marginBottom = "-1px"; const form = document.createElement("form"); form.noValidate = true; const labelDiv = document.createElement("div"); labelDiv.className = "setting-label"; labelDiv.textContent = title; labelDiv.style.color = "#494950"; labelDiv.style.position = "relative"; const valueDiv = document.createElement("div"); valueDiv.className = "setting-value"; valueDiv.style.marginTop = "6px"; const select = document.createElement("select"); select.className = "valid"; for (const optionName of optionNames) { const option = document.createElement("option"); option.value = optionName; option.textContent = optionName; option.selected = optionName === defaultOptionName; select.appendChild(option); } select.addEventListener("change", onChange); valueDiv.appendChild(select); form.appendChild(labelDiv); form.appendChild(valueDiv); wrapperDiv.appendChild(form); fragment.appendChild(wrapperDiv); return fragment; } function buildTableOptions(table2) { const tableParent = assertExists( table2.parentElement, "Table parent not found." ); const currentItemsPerPage = Number(getQueryParam(QUERY_PARAM.itemsPerPage)) ?? DEFAULT.itemsPerPage; const itemsPerPageSelector = createSelector( MSG.itemsPerPage, ITEMS_PER_PAGE, currentItemsPerPage, ({ target }) => { const url = new URL(window.location.href); url.searchParams.set(QUERY_PARAM.itemsPerPage, target.value); window.location.href = url.toString(); } ); const genderSelector = createSelector( MSG.gender, Object.values(GENDER), DEFAULT.gender, ({ target }) => { state.gender = target.value; updateTable(); sortTableByCol(); } ); tableParent.insertBefore(itemsPerPageSelector, table2); tableParent.insertBefore(genderSelector, table2); } function buildTableHeaders(headerRow2) { const rowNumCol = createCol(COL_INDEX.rowNum); const recordDiffSecondsCol = createCol(COL_INDEX.recordDiffSeconds); const recordDiffPercentageCol = createCol(COL_INDEX.recordDiffPercentage); headerRow2.insertBefore(rowNumCol, headerRow2.firstChild); headerRow2.appendChild(recordDiffSecondsCol); headerRow2.appendChild(recordDiffPercentageCol); [...headerRow2.children].forEach((th, i) => { th.style.cursor = "pointer"; th.addEventListener("click", () => sortTableByCol(i, true)); }); } function buildTableCols(rows2) { const pageNum = Number(getQueryParam(QUERY_PARAM.pageNum) ?? 1); const startIndex = (pageNum - 1) * DEFAULT.itemsPerPage + 1; rows2.forEach((row, i) => { const indexCell = row.insertCell(0); indexCell.textContent = startIndex + i; row.insertCell(); row.insertCell(); }); } function buildTable() { const table2 = assertExists( document.querySelector("table.starred-segments"), "Table not found." ); const headerRow2 = assertExists( table2.querySelector("thead tr"), "Table header row not found." ); const rows2 = [...table2.querySelectorAll("tbody tr")]; buildTableOptions(table2); buildTableHeaders(headerRow2); buildTableCols(rows2); return [table2, headerRow2, rows2]; } function updateTable() { const gender = state.gender; for (const row of rows) { const cells = row.children; const [menRecordSeconds, womenRecordSeconds, myRecordSeconds] = [ "menRecord", "womenRecord", "myRecord" ].map((key) => parseDuration(cells[COL_INDEX[key]].textContent.trim())); let recordSeconds = menRecordSeconds; if (gender === GENDER.women) { recordSeconds = womenRecordSeconds; } else if (gender === GENDER.any) { recordSeconds = Math.min( menRecordSeconds ?? Number.POSITIVE_INFINITY, womenRecordSeconds ?? Number.POSITIVE_INFINITY ); } const recordDiffSecondsCell = cells[COL_INDEX.recordDiffSeconds]; const recordDiffPercentageCell = cells[COL_INDEX.recordDiffPercentage]; if (recordSeconds != null && myRecordSeconds != null) { const secondsDiff = myRecordSeconds - recordSeconds; const percentDiff = secondsDiff / recordSeconds * 100; const color = getColorFor(percentDiff); recordDiffSecondsCell.textContent = `${getSignFor(secondsDiff)}${formatTimeDiff(secondsDiff)}`; recordDiffPercentageCell.textContent = `${getSignFor(percentDiff)}${Math.abs(percentDiff).toFixed(0)}${MSG.percentSuffix}`; recordDiffSecondsCell.style.color = color; recordDiffPercentageCell.style.color = color; } else { recordDiffSecondsCell.textContent = MSG.placeholderValue; recordDiffPercentageCell.textContent = MSG.placeholderValue; recordDiffSecondsCell.style.color = ""; recordDiffPercentageCell.style.color = ""; } } } function sortTableByCol(colIndex = state.sort.colIndex, toggleDirection = false) { const { parseFn } = COLS[colIndex]; if (!parseFn) { console.warn(`No parse function defined for column ${colIndex}`); return; } const direction = state.sort.direction * (toggleDirection ? -1 : 1); state.sort.colIndex = colIndex; state.sort.direction = direction; rows.sort((rowA, rowB) => { const [valA, valB] = [rowA, rowB].map( (row) => parseFn(row.children[colIndex].textContent.trim()) ?? Number.POSITIVE_INFINITY ); if (typeof valA === "number" && typeof valB === "number") { return (valA - valB) * direction; } return String(valA).localeCompare(String(valB)) * direction; }); [...headerRow.children].forEach((th, i) => { th.innerHTML = th.innerHTML.replace(sortIndicatorRegex, ""); if (i === colIndex) { th.innerHTML += SORT_INDICATOR[direction]; } }); const tbody = table.querySelector("tbody"); for (const row of rows) { tbody.appendChild(row); } } }; const starredSegmentsRoute = { pattern, handler }; const registerRouteHandlers = (routes) => { const path = location.pathname; for (const { pattern: pattern2, handler: handler2 } of routes) { if (pattern2.test(path)) { handler2(); break; } } }; const init = () => { debug("Script loaded"); registerRouteHandlers([starredSegmentsRoute]); debug("Script unloaded"); }; init(); })();