diff --git a/packages/onsite-toolkit/frontend/resolver.page..tsx b/packages/onsite-toolkit/frontend/resolver.page..tsx new file mode 100644 index 000000000..3affec267 --- /dev/null +++ b/packages/onsite-toolkit/frontend/resolver.page..tsx @@ -0,0 +1,280 @@ +/* eslint-disable no-await-in-loop */ +import { animated, easings, useSprings } from '@react-spring/web'; +import useKey from 'react-use/lib/useKey'; +import { + addPage, NamedPage, React, ReactDOM, +} from '@hydrooj/ui-default'; +import { ResolverInput } from '../interface'; + +async function scrollTo(offset) { + const fixedOffset = offset.toFixed(); + await new Promise((resolve) => { + const onScroll = function () { + if (window.pageYOffset.toFixed() === fixedOffset) { + window.removeEventListener('scroll', onScroll); + resolve(null); + } + }; + + window.addEventListener('scroll', onScroll); + onScroll(); + window.scrollTo({ + top: offset, + behavior: 'smooth', + }); + }); +} +export interface DisplaySettings { + showAvatar: boolean; + showSchool: boolean; +} + +interface Props extends DisplaySettings { + data: ResolverInput; +} + +function status(problem) { + if (!problem) return 'untouched'; + if (problem.pass) return 'ac'; + if (!problem.old && !problem.frozen) return 'untouched'; + if (problem.frozen) return 'frozen'; + return 'failed'; +} + +function submissions(problem) { + const st = status(problem); + if (st === 'ac') { return `${problem.old}`; } + if (st === 'frozen') { return `${problem.old}+${problem.frozen}`; } + if (st === 'failed') { return problem.old; } + return String.fromCharCode('A'.charCodeAt(0) + problem.index); +} + +export function start(data: ResolverInput, options: DisplaySettings) { + $('title').text(`${data.name} - Resolver`); + $('.header .title').text(`${data.name}`); + const teams = data.teams.map((v) => ({ + id: v.id, + rank: 0, + score: 0, + penalty: 0, + ranked: !v.exclude, + total: 0, + problems: data.problems.map((problem, idx) => ({ + old: 0, + frozen: 0, + pass: false, + id: problem.id, + index: idx, + })), + })); + const allSubmissions = data.submissions.sort((a, b) => a.time - b.time); + const allAc = allSubmissions.filter((i) => i.verdict === 'AC'); + for (const submission of allSubmissions) { + const team = teams.find((v) => v.id === submission.team); + if (!team) continue; + const isFrozen = submission.time > data.frozen; + const problem = team.problems.find((i) => i.id === submission.problem); + if (!problem || problem.pass) continue; + team.total++; + if (isFrozen) problem.frozen += 1; + else { + if (submission.verdict === 'AC') { + problem.pass = true; + team.score += 1; + team.penalty += submission.time + problem.old * 20 * 60; + } + problem.old += 1; + } + } + + function MainList(props: Props) { + const [selectedTeam, setTeam] = React.useState(''); + const [selectedProblem, setP] = React.useState(null); + const [executeIdx, setExecuteIdx] = React.useState(0); + const [, setRenderC] = React.useState(0); + + function processRank(source = teams) { + const clone = [...source]; + clone.sort((a, b) => b.score - a.score || a.penalty - b.penalty || b.total - a.total); + let rank = 1; + for (const team of clone) { + if (team.ranked) { + team.rank = rank; + rank++; + } else { + team.rank = -1; + } + } + return clone.map((i) => source.indexOf(i)); + } + + const order = React.useRef(processRank()); + + const [springs, api] = useSprings(teams.length, (index) => ({ + y: order.current.indexOf(index) * 80 - index * 80, + scale: 1, + zIndex: 0, + shadow: 1, + immediate: (key: string) => key === 'y' || key === 'zIndex', + })); + + const operations = { + async highlightTeam(teamId: string, scrollIdx: number) { + setP(null); + setTeam(teamId); + await scrollTo(scrollIdx * 80 - window.innerHeight + 241 + 40); + }, + async highlightProblem(problemId: string) { + setP(problemId); + }, + async revealProblem(teamId: string, problemId: string) { + const team = teams.find((i) => i.id === teamId); + const problem = team?.problems.find((i) => i.id === problemId); + if (!team || !problem) return; + if (allAc.find((s) => s.team === teamId && s.problem === problemId)) { + const sub = allSubmissions.filter((s) => s.team === teamId && s.problem === problemId); + let penalty = 0; + for (const s of sub) { + if (s.verdict !== 'AC') { + penalty += 20 * 60; + problem.old++; + } else { + penalty += s.time; + break; + } + } + team.penalty += penalty; + team.score += 1; + problem.pass = true; + problem.frozen = 0; + } else { + problem.old += problem.frozen; + problem.frozen = 0; + } + setP(null); + }, + async updateRank() { + order.current = processRank(); + api.start((index) => ({ + y: order.current.indexOf(index) * 80 - index * 80, + scale: 1, + zIndex: 0, + shadow: 1, + config: { + easing: easings.steps(5), + }, + })); + }, + }; + + const calculated = React.useMemo(() => { + window.scrollTo(0, document.body.scrollHeight); + const clone = JSON.parse(JSON.stringify(teams)); + const ops: { name: string, args: any[] }[] = []; + function queueOperations(name: string, ...args: any[]) { + ops.push({ name, args }); + } + let orders = processRank(clone); + for (let i = clone.length - 1; i > 0; i--) { + const team = clone[orders[i]]; + queueOperations('highlightTeam', team.id, i); + for (const pinfo of data.problems) { + const problem = team.problems.find((idx) => idx.id === pinfo.id); + if (!problem || !problem.frozen || problem.pass) continue; + queueOperations('highlightProblem', pinfo.id); + queueOperations('revealProblem', team.id, pinfo.id); + // scroll to selected line + if (allAc.find((s) => s.team === team.id && s.problem === problem.id)) { + const sub = allSubmissions.filter((s) => s.team === team.id && s.problem === problem.id); + let penalty = 0; + for (const s of sub) { + if (s.verdict !== 'AC') { + penalty += 20 * 60; + problem.old++; + } else { + penalty += s.time; + break; + } + } + team.penalty += penalty; + team.score += 1; + problem.pass = true; + problem.frozen = 0; + queueOperations('updateRank'); + const oldOrder = JSON.stringify(orders); + orders = processRank(clone); + if (oldOrder !== JSON.stringify(orders)) { + i++; + break; + } + } else { + problem.old += problem.frozen; + problem.frozen = 0; + } + } + } + return ops; + }, [data]); + + useKey('n', async () => { + const op = calculated[executeIdx]; + if (!op) return; + setExecuteIdx(executeIdx + 1); + await operations[op.name](...op.args); + setRenderC((i) => i + 1); + }, {}, [executeIdx, calculated]); + + return (<> + {springs.map(({ + zIndex, y, + }, i) => { + const team = teams[i]; + const teamInfo = data.teams.find((idx) => idx.id === team.id); + if (!teams[i]) return Team {i} not found; + if (!teamInfo) return Team info for id {team.id} not found; + return `rgba(0, 0, 0, 0.15) 0px ${s}px ${2 * s}px 0px`), + y, + ...(selectedTeam === team.id ? { + backgroundColor: '#406b82', + } : { + background: 'transparent', + }), + }} + children={<> +
{team.rank === -1 ? '*' : team.rank}
+ {props.showAvatar && } +
+
+ {props.showSchool ? `${teamInfo.institution} - ` : ''}{teamInfo.name} +
+
+ {data.problems.map((v) => { + const uncover = team.id === selectedTeam && selectedProblem === v.id; + const problemStatus = team.problems.find((idx) => idx.id === v.id); + return + {submissions(problemStatus)} + ; + })} +
+
+
{team.score}
+
{Math.floor(team.penalty / 60)}
+ } + />; + })} + ); + } + ReactDOM.createRoot(document.getElementById('rank-list')!).render(); +} + +addPage(new NamedPage(['resolver'], () => { + start(UiContext.payload, { + showAvatar: true, + showSchool: true, + }); +})); diff --git a/packages/onsite-toolkit/index.ts b/packages/onsite-toolkit/index.ts index 0e0fc30ce..006848cbb 100644 --- a/packages/onsite-toolkit/index.ts +++ b/packages/onsite-toolkit/index.ts @@ -1,6 +1,8 @@ import { - Context, db, ForbiddenError, UserModel, + avatar, ContestModel, Context, db, ForbiddenError, + ObjectId, PERM, STATUS, Time, UserModel, } from 'hydrooj'; +import { ResolverInput } from './interface'; interface IpLoginInfo { _id: string; @@ -33,4 +35,45 @@ export function apply(ctx: Context) { that.session.user = that.user; } }); + + ctx.inject(['scoreboard'], ({ scoreboard }) => { + scoreboard.addView('resolver-tiny', 'Resolver(Tiny)', { tdoc: 'tdoc' }, { + async display({ tdoc }) { + if (!this.user.own(tdoc)) this.checkPerm(PERM.PERM_VIEW_CONTEST_HIDDEN_SCOREBOARD); + const teams = await ContestModel.getMultiStatus(tdoc.domainId, { docId: tdoc.docId }).toArray(); + const udict = await UserModel.getList(tdoc.domainId, teams.map((i) => i.uid)); + const teamIds: Record = {}; + for (let i = 1; i <= teams.length; i++) teamIds[teams[i - 1].uid] = i; + const time = (t: ObjectId) => Math.floor((t.getTimestamp().getTime() - tdoc.beginAt.getTime()) / Time.second); + const pid = (i: number) => String.fromCharCode(65 + i); + const unknownSchool = this.translate('Unknown School'); + const submissions = teams.flatMap((i) => { + if (!i.journal) return []; + return i.journal.filter((s) => tdoc.pids.includes(s.pid)).map((s) => ({ ...s, uid: i.uid })); + }); + this.response.body = { + payload: { + name: tdoc.title, + duration: Math.floor((new Date(tdoc.endAt).getTime() - new Date(tdoc.beginAt).getTime()) / 1000), + frozen: Math.floor((new Date(tdoc.endAt).getTime() - new Date(tdoc.lockAt).getTime()) / 1000), + problems: tdoc.pids.map((i, n) => ({ name: pid(n), id: i.toString() })), + teams: teams.map((t) => ({ + id: t.uid.toString(), + name: udict[t.uid].uname, + avatar: avatar(udict[t.uid].avatar), + institution: udict[t.uid].school || unknownSchool, + })), + submissions: submissions.map((i) => ({ + team: i.uid.toString(), + problem: i.pid.toString(), + verdict: i.status === STATUS.STATUS_ACCEPTED ? 'AC' : 'RJ', + time: time(i.rid), + })), + } as ResolverInput, + }; + this.response.template = 'resolver.html'; + }, + supportedRules: ['acm'], + }); + }); } diff --git a/packages/onsite-toolkit/interface.ts b/packages/onsite-toolkit/interface.ts new file mode 100644 index 000000000..d7f02dc85 --- /dev/null +++ b/packages/onsite-toolkit/interface.ts @@ -0,0 +1,33 @@ +type Verdict = 'RJ' | 'AC' | 'NA'; +export interface ProblemInfo { + color?: string; // hex color + id: string; + name: string; // A, B, C, ... +} +export interface TeamInfo { + id: string; + name: string; + avatar?: string; + institution?: string; + exclude?: boolean; // false by default +} +export interface InstitiutionInfo { + id: string; + name: string; + avatar?: string; +} +export interface SubmissionInfo { + team: string; + problem: string; + verdict: Verdict; + time: number; // in seconds +} +export interface ResolverInput { + name: string; + duration: number; // in seconds + frozen: number; // in seconds + problems: ProblemInfo[]; + submissions: SubmissionInfo[]; + teams: TeamInfo[]; + institutions: Record; +} diff --git a/packages/onsite-toolkit/package.json b/packages/onsite-toolkit/package.json index d1ccd0de0..18dbe74ef 100644 --- a/packages/onsite-toolkit/package.json +++ b/packages/onsite-toolkit/package.json @@ -1,4 +1,8 @@ { "name": "@hydrooj/onsite-toolkit", - "version": "0.0.1" + "version": "0.0.1", + "dependencies": { + "@react-spring/web": "^9.7.3", + "react-use": "^17.4.2" + } } diff --git a/packages/onsite-toolkit/public/resolver.css b/packages/onsite-toolkit/public/resolver.css new file mode 100644 index 000000000..d324d43b8 --- /dev/null +++ b/packages/onsite-toolkit/public/resolver.css @@ -0,0 +1,110 @@ +body { + color: #eee; + background-color: #121212; +} +.rank-list { + background: repeating-linear-gradient( + 180deg, + #3e3e3e 0px, + #3e3e3e 80px, + #121212 80px, + #121212 160px + ); +} +.rank-list-item { + padding: 10px 0; + height: 80px; + position: relative; + background: transparent; + display: flex; + align-items: center; + vertical-align: middle; +} +.header { + display: flex; + align-items: center; + padding: 10px 0; + height: 40px; +} +.header .rank, .header .solved, .header .penalty { + width: 90px; + font-size: 20px; + text-align: center; +} +.header .content { + flex-grow: 1; + text-align: left; + display: flex; + justify-content: center; + align-items: center; +} +.header .content .title { + flex-grow: 1; + font-size: 20px; + text-align: left; + display: flex; +} +.header .content .copyright { + color: #8a919b; + font-size: 15px; +} +.rank-list-item .rank, .rank-list-item .solved, .rank-list-item .penalty { + padding: 10px 0px; + width: 90px; + font-size: 40px; + text-align: center; + vertical-align: middle; +} +.rank-list-item .avatar { + height: 60px; + width: 60px; + border-radius: 5px; + margin-right: 15px; +} +.rank-list-item .content { + flex-grow: 1; + height: 60px +} +.rank-list-item .content .name { + font-size: 30px; + margin-bottom: 6px; +} +.rank-list-item .content .problems { + display: flex; + align-items: center; + gap: 5px; +} +.rank-list-item .problems .item { + padding: 2px; + width: 95px; + text-align: center; + border-radius: 5px; + font-size: 20px; +} +.ac { + background-color: #009c00; +} +.failed, .WA { + background-color: #dd514c; +} +.frozen { + background-color: #3c6ef6; +} +.untouched { + background-color: #282828; + color: #8a919b; +} +.uncover { + animation: flashing 1000ms infinite; + -webkit-animation: flashing 1000ms infinite; /*Safari and Chrome*/ +} +@keyframes flashing { + from { background-color: #3c6ef6 } + 50% { background-color: #7699ea } + to { background-color: #3c6ef6 } +} +@-webkit-keyframes flashing {/*Safari and Chrome*/ + from { background-color: #3c6ef6 } + 50% { background-color: #7699ea } + to { background-color: #3c6ef6 } +} \ No newline at end of file diff --git a/packages/onsite-toolkit/templates/resolver.html b/packages/onsite-toolkit/templates/resolver.html new file mode 100644 index 000000000..9a0c437ec --- /dev/null +++ b/packages/onsite-toolkit/templates/resolver.html @@ -0,0 +1,15 @@ +{% extends "layout/html5.html" %} +{% block body %} +{{ set(UiContext, 'payload', payload) }} +
+ #Rank + + Contest + @Hydro/TinyResolver + + Solved + Penalty +
+
+ +{% endblock %} diff --git a/packages/ui-default/backendlib/builder.ts b/packages/ui-default/backendlib/builder.ts index 23fe6b739..d85b9b5d7 100644 --- a/packages/ui-default/backendlib/builder.ts +++ b/packages/ui-default/backendlib/builder.ts @@ -29,14 +29,33 @@ const tmp = tmpdir(); const federationPlugin: esbuild.Plugin = { name: 'federation', setup(b) { + const packages = { + react: 'React', + 'react-dom': 'ReactDOM', + jquery: '$', + }; b.onResolve({ filter: /^@hydrooj\/ui-default/ }, () => ({ path: 'api', namespace: 'ui-default', })); - b.onLoad({ filter: /.*/, namespace: 'ui-default' }, () => ({ - contents: 'module.exports = window.HydroExports;', - loader: 'tsx', - })); + for (const key in packages) { + b.onResolve({ filter: new RegExp(`^${key}($|\\/)`) }, () => ({ + path: packages[key], + namespace: 'ui-default', + })); + } + b.onLoad({ filter: /.*/, namespace: 'ui-default' }, (args) => { + if (args.path === 'api') { + return { + contents: 'module.exports = window.HydroExports;', + loader: 'tsx', + }; + } + return { + contents: `module.exports = window.HydroExports['${args.path}'];`, + loader: 'tsx', + }; + }); }, };