AtCoder Problems Problem List Sort Marker

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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 });
	}
})();