AtCoder Problems Problem List Sort Marker

AtCoder Problems の Problem List の現在のソート列に ▲ または ▼ を表示します。

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==UserScript==
// @name         AtCoder Problems Problem List Sort Marker
// @namespace    http://tampermonkey.net/
// @version      2026.07.07
// @description  AtCoder Problems の Problem List の現在のソート列に ▲ または ▼ を表示します。
// @author       Not_Leonian
// @match        https://kenkoooo.com/atcoder*
// @icon         https://www.google.com/s2/favicons?domain=https://kenkoooo.com/atcoder
// @run-at       document-idle
// @license      MIT
// ==/UserScript==

(() => {
	"use strict";

	const ASC_MARK = "▲";
	const DESC_MARK = "▼";

	const MARKER_CLASS = "acp-sort-marker";
	const ACTIVE_TH_CLASS = "acp-sort-active";
	const STYLE_ID = "acp-sort-marker-style";

	const FIELD_TO_HEADER = new Map([
		["contestDate", "Date"],
		["title", "Problem"],
		["id", "Problem"],
		["mergedProblem", "Problem"],
		["contest", "Contest"],
		["contestTitle", "Contest"],
		["status", "Result"],
		["lastAcceptedDate", "Last AC Date"],
		["solverCount", "Solvers"],
		["point", "Point"],
		["problemModel", "Difficulty"],
		["solveProbability", "Solve Prob"],
		["timeEstimation", "Time"],
		["executionTime", "Fastest"],
		["fastestUserId", "Fastest"],
		["codeLength", "Shortest"],
		["shortestUserId", "Shortest"],
		["firstUserId", "First"],
	]);

	const VALID_SORT_FIELDS = new Set([
		...FIELD_TO_HEADER.keys(),
		"id",
		"contestTitle",
		"mergedProblem",
		"shortestUserId",
		"fastestUserId",
	]);

	const PROBLEM_LIST_SIGNATURE_FIELDS = [
		"contestDate",
		"title",
		"contest",
		"point",
		"problemModel",
	];
	const PROBLEM_LIST_SIGNATURE_HEADERS = [
		"Date",
		"Problem",
		"Contest",
		"Point",
		"Difficulty",
	];
	const MIN_SIGNATURE_MATCHES = 3;

	function injectStyle() {
		if (document.getElementById(STYLE_ID)) return;

		const styleParent = document.head || document.documentElement;
		if (!styleParent) return;

		const style = document.createElement("style");
		style.id = STYLE_ID;
		style.textContent = `
		th.${ACTIVE_TH_CLASS} {
			position: relative !important;
			padding-inline-end: 1.35em !important;
		}

		th.${ACTIVE_TH_CLASS} > .${MARKER_CLASS} {
			position: absolute;
			inset-inline-end: 0.35em;
			top: 50%;
			z-index: 1;
			transform: translateY(-50%);
			font-size: 0.85em;
			font-weight: 700;
			line-height: 1;
			pointer-events: none;
			user-select: none;
		}
	`;
		styleParent.appendChild(style);
	}

	function normalizePath(path) {
		const normalized = path.replace(/\/+$/g, "");
		return normalized || "/";
	}

	function removeBasePath(pathname) {
		if (pathname === "/atcoder") return "/";
		if (pathname.startsWith("/atcoder/"))
			return pathname.slice("/atcoder".length);
		return pathname;
	}

	function getRouteUrl() {
		const hash = location.hash.startsWith("#")
			? location.hash.slice(1)
			: location.hash;
		if (!hash) return null;

		try {
			return new URL(hash, location.origin);
		} catch {
			return null;
		}
	}

	function isListPath(path) {
		const normalized = normalizePath(path);
		return normalized === "/list" || normalized.startsWith("/list/");
	}

	function isProblemListPage() {
		const routeUrl = getRouteUrl();
		if (routeUrl) return isListPath(routeUrl.pathname);

		const browserPath = removeBasePath(location.pathname || "/");
		return isListPath(browserPath);
	}

	function getRouteSearchParams() {
		const routeUrl = getRouteUrl();
		if (routeUrl) return routeUrl.searchParams;

		const questionIndex = location.hash.indexOf("?");
		if (questionIndex >= 0) {
			return new URLSearchParams(location.hash.slice(questionIndex + 1));
		}

		return new URLSearchParams(location.search);
	}

	function getSortState() {
		const params = getRouteSearchParams();
		const rawField = params.get("sortBy");
		const field =
			rawField && VALID_SORT_FIELDS.has(rawField) ? rawField : "contestDate";
		const order = params.get("sortOrder") === "asc" ? "asc" : "desc";
		return { field, order };
	}

	function escapeCssAttributeValue(value) {
		if (window.CSS && typeof window.CSS.escape === "function") {
			return window.CSS.escape(value);
		}
		return value.replace(/["\\]/g, "\\$&");
	}

	function normalizeHeaderText(text) {
		return text
			.replace(/[▲▼△▽▴▾↑↓]/g, "")
			.replace(/\s+/g, " ")
			.trim();
	}

	function isVisibleElement(element) {
		const style = getComputedStyle(element);
		return style.display !== "none" && style.visibility !== "hidden";
	}

	function getHeaderScope(element) {
		return (
			element.closest(".react-bs-table-container") ||
			element.closest(".react-bs-container-header") ||
			element.closest("thead") ||
			document
		);
	}

	function getFirstHeaderCells(scope) {
		const row = scope.querySelector("thead tr") || scope.querySelector("tr");
		if (!row) return [];
		return Array.from(row.querySelectorAll("th")).filter(isVisibleElement);
	}

	function countMatches(values, candidates) {
		let count = 0;
		for (const candidate of candidates) {
			if (values.has(candidate)) count += 1;
		}
		return count;
	}

	function hasProblemListSignature(values, candidates, anchors) {
		const matches = countMatches(values, candidates);
		if (matches < MIN_SIGNATURE_MATCHES) return false;

		for (const anchor of anchors) {
			if (values.has(anchor)) return true;
		}

		return false;
	}

	function scopeLooksLikeProblemList(scope) {
		const dataFields = new Set(
			Array.from(scope.querySelectorAll("th[data-field]"))
				.filter(isVisibleElement)
				.map((th) => th.getAttribute("data-field") || ""),
		);

		if (
			hasProblemListSignature(dataFields, PROBLEM_LIST_SIGNATURE_FIELDS, [
				"contestDate",
				"title",
			])
		) {
			return true;
		}

		const headerTexts = new Set(
			getFirstHeaderCells(scope).map((th) =>
				normalizeHeaderText(th.textContent || ""),
			),
		);
		return hasProblemListSignature(
			headerTexts,
			PROBLEM_LIST_SIGNATURE_HEADERS,
			["Date", "Problem"],
		);
	}

	function getProblemListHeaderScopes() {
		const scopes = [];
		const seen = new Set();
		const headerCells = document.querySelectorAll(
			".react-bs-container-header th, thead th",
		);

		for (const th of headerCells) {
			const scope = getHeaderScope(th);
			if (seen.has(scope)) continue;
			seen.add(scope);
			if (scopeLooksLikeProblemList(scope)) scopes.push(scope);
		}

		return scopes;
	}

	function findHeaderCellByDataField(field) {
		const escapedField = escapeCssAttributeValue(field);
		const selector = `.react-bs-container-header th[data-field="${escapedField}"], thead th[data-field="${escapedField}"]`;
		const candidates = Array.from(document.querySelectorAll(selector)).filter(
			isVisibleElement,
		);

		for (const th of candidates) {
			if (scopeLooksLikeProblemList(getHeaderScope(th))) return th;
		}

		return candidates[0] || null;
	}

	function findHeaderCellByText(field) {
		const expectedHeader = FIELD_TO_HEADER.get(field);
		if (!expectedHeader) return null;

		for (const scope of getProblemListHeaderScopes()) {
			for (const th of getFirstHeaderCells(scope)) {
				if (normalizeHeaderText(th.textContent || "") === expectedHeader)
					return th;
			}
		}

		return null;
	}

	function findTargetHeaderCell(field) {
		return findHeaderCellByDataField(field) || findHeaderCellByText(field);
	}

	function clearMarkers() {
		for (const marker of document.querySelectorAll(`.${MARKER_CLASS}`)) {
			marker.remove();
		}

		for (const th of document.querySelectorAll(`th.${ACTIVE_TH_CLASS}`)) {
			th.classList.remove(ACTIVE_TH_CLASS);
		}
	}

	function isMarkerAlreadyCorrect(targetTh, mark) {
		const markers = Array.from(document.querySelectorAll(`.${MARKER_CLASS}`));
		const activeHeaders = Array.from(
			document.querySelectorAll(`th.${ACTIVE_TH_CLASS}`),
		);
		return (
			markers.length === 1 &&
			activeHeaders.length === 1 &&
			markers[0].parentElement === targetTh &&
			markers[0].textContent === mark &&
			activeHeaders[0] === targetTh
		);
	}

	let isApplying = false;

	function applyMarker() {
		if (isApplying) return;
		isApplying = true;

		try {
			injectStyle();

			if (!isProblemListPage()) {
				clearMarkers();
				return;
			}

			const { field, order } = getSortState();
			const targetTh = findTargetHeaderCell(field);
			if (!targetTh) {
				clearMarkers();
				return;
			}

			const mark = order === "asc" ? ASC_MARK : DESC_MARK;
			if (isMarkerAlreadyCorrect(targetTh, mark)) return;

			clearMarkers();

			const marker = document.createElement("span");
			marker.className = MARKER_CLASS;
			marker.textContent = mark;
			marker.setAttribute("aria-hidden", "true");
			marker.setAttribute("data-atcoder-problems-sort-marker", "true");

			targetTh.classList.add(ACTIVE_TH_CLASS);
			targetTh.appendChild(marker);
		} finally {
			setTimeout(() => {
				isApplying = false;
			}, 0);
		}
	}

	let scheduled = 0;

	function scheduleApply() {
		if (scheduled) cancelAnimationFrame(scheduled);
		scheduled = requestAnimationFrame(() => {
			scheduled = 0;
			applyMarker();
		});
	}

	function hookHistoryMethod(name) {
		const original = history[name];
		if (typeof original !== "function") return;

		try {
			history[name] = function (...args) {
				const result = original.apply(this, args);
				scheduleApply();
				return result;
			};
		} catch {}
	}

	function installStartupRetries() {
		let count = 0;
		const timerId = setInterval(() => {
			count += 1;
			scheduleApply();
			if (count >= 20) clearInterval(timerId);
		}, 500);
	}

	function start() {
		injectStyle();
		hookHistoryMethod("pushState");
		hookHistoryMethod("replaceState");

		window.addEventListener("hashchange", scheduleApply);
		window.addEventListener("popstate", scheduleApply);
		window.addEventListener("load", scheduleApply);

		const observer = new MutationObserver(() => {
			if (!isApplying) scheduleApply();
		});
		observer.observe(document.body, {
			attributes: true,
			attributeFilter: ["class", "data-field", "style"],
			childList: true,
			subtree: true,
		});

		installStartupRetries();
		scheduleApply();
	}

	if (document.body) {
		start();
	} else {
		window.addEventListener("DOMContentLoaded", start, { once: true });
	}
})();