Files
E-Vote/resources/assets/js/components/demo/RouletteEliminationWheel.vue
T

1360 lines
32 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="roulette-elimination" :class="{ 'roulette-elimination--full-width': fullWidth }">
<div class="row roulette-main-row">
<div class="roulette-wheel-col" :class="wheelColumnClass">
<div class="wheel-stage" role="button" :tabindex="wheelStageTabindex" :aria-disabled="!wheelCanSpin"
:aria-label="spinning ? 'Roda sedang berputar' : 'Putar roda klik pada roda'"
:class="{ 'wheel-stage--can-spin': wheelCanSpin }" @click="spin" @keydown.enter.prevent="spin"
@keydown.space.prevent="spin">
<div class="pointer" aria-hidden="true"></div>
<div class="wheel-wrap" :style="wheelStyle" @transitionend="onSpinEnd">
<canvas ref="wheelCanvas" class="wheel-canvas"></canvas>
</div>
<div v-if="showCenterLogo && centerLogoSrc" class="wheel-center-logo" aria-hidden="true">
<img :src="centerLogoSrc" alt="" class="wheel-center-logo-img" draggable="false" />
</div>
</div>
<p v-if="wheelCanSpin" class="text-muted roulette-click-hint small">
Klik pada papan roda untuk putar.
</p>
<p v-if="enableSpaceToSpin && showSpaceShortcutHint && remaining.length >= 2 && !spinning"
class="text-muted roulette-space-hint small">
Pintasan: tekan <kbd>Space</kbd> untuk putar.
</p>
<!-- <p v-if="persistKey && showClearPersistedLink" class="roulette-persist-clear">
<button type="button" class="btn btn-link btn-xs" :disabled="spinning" @click="clearPersistedState">
Padam sejarah &amp; mulai semula
</button>
</p> -->
<p v-if="poolSize < 2" class="text-muted hint">
Tambah sekurang-kurangnya dua nama untuk bermula.
</p>
<p v-else-if="remaining.length < 2" class="text-muted hint">
Kurang daripada dua peserta dalam roda tidak boleh putar lagi.
</p>
<p v-if="lastWinner && !spinning" class="last-winner">
Terpilih: <strong>{{ lastWinner }}</strong>
</p>
</div>
<div v-if="participantPool.length" class="roulette-side-col" :class="sideColumnClass">
<div class="panel panel-default roulette-panel text-left">
<div class="panel-heading">Peserta</div>
<ul class="list-group participant-list participant-list--scroll">
<li v-for="p in participantPool" :key="p.id" class="list-group-item participant-row"
:class="participantRowClass(p)">
<span class="participant-name">{{ p.label }}</span>
<span class="participant-badge">{{ participantBadge(p) }}</span>
</li>
</ul>
</div>
<div class="panel panel-default roulette-panel text-left">
<div class="panel-heading">Pemenang (urutan cabutan)</div>
<ol v-if="winnerHistory.length" class="winner-ol winner-ol--scroll">
<li v-for="(w, i) in winnerHistory" :key="'wh-' + w.id + '-' + i" class="winner-li">
<span class="winner-li-main">
<span class="winner-label">{{ w.label }}</span>
<span class="winner-meta text-muted">Pusingan {{ w.round }}</span>
</span>
<button v-if="allowMarkAbsent" type="button" class="btn btn-link btn-xs winner-absent-btn"
:disabled="spinning" @click.stop="markWinnerAbsentAtHistoryIndex(i)">
Tidak hadir
</button>
</li>
</ol>
<div v-else class="panel-body text-muted winner-empty">
Belum ada cabutan.
</div>
</div>
</div>
</div>
<div class="modal fade app-modal roulette-winner-modal" id="roulette-winner-modal" tabindex="-1" role="dialog"
aria-labelledby="roulette-winner-title" aria-hidden="true">
<div class="modal-dialog modal-lg roulette-winner-modal__dialog" role="document">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal" aria-label="Tutup">
<span aria-hidden="true">&times;</span>
</button>
<h3 class="modal-title roulette-winner-modal__title" id="roulette-winner-title">
Tahniah
</h3>
</div>
<div class="modal-body text-center" v-if="modalWinnerDisplay">
<p class="roulette-winner-modal-round text-muted">
Pusingan {{ modalWinnerDisplay.round }}
</p>
<p class="roulette-winner-modal-name">{{ modalWinnerDisplay.label }}</p>
<!-- <p v-if="allowMarkAbsent" class="roulette-winner-absent-hint text-muted small">
Jika pemenang tidak hadir, nama dibuang daripada senarai pemenang dan tidak dimasukkan
semula ke dalam roda.
</p> -->
</div>
<div class="modal-footer clearfix">
<button v-if="allowMarkAbsent" type="button" class="btn btn-default pull-left"
:disabled="spinning" @click="onWinnerAbsentFromModal">
Tidak hadir keluarkan
</button>
<button type="button" class="btn btn-primary pull-right" data-dismiss="modal">
Tutup
</button>
</div>
</div>
</div>
</div>
</div>
</template>
<script>
/**
* Roulette wheel: random winner per spin, then that entry is removed (elimination).
* Vue 2 Options API — drop into a parent or register globally.
*/
export default {
name: 'RouletteEliminationWheel',
props: {
/**
* Wheel entries: strings (legacy index id) or { id, label } with stable id.
* Prefer objects when the list can grow (imports) without resetting game state.
*/
items: {
type: Array,
default: function () {
return ['Ali', 'Siti', 'Muthu', 'Aminah', 'David'];
},
},
/** Minimum full rotations before stopping. */
minSpins: {
type: Number,
default: 6,
},
/** Spin duration in ms (CSS transition length). */
durationMs: {
type: Number,
default: 9000,
},
/** Press Space to spin (ignored in inputs / when winner modal is open). */
enableSpaceToSpin: {
type: Boolean,
default: true,
},
/** Show one-line Space shortcut hint under the wheel. */
showSpaceShortcutHint: {
type: Boolean,
default: true,
},
/** Show a modal with the winner after each spin. */
showWinnerModal: {
type: Boolean,
default: true,
},
/**
* localStorage key for persisting cabutan across refresh (same peserta list).
* Set to empty string to disable persistence.
*/
persistKey: {
type: String,
default: 'voting_roulette_elimination',
},
/** Show link to clear saved state and restart from current list. */
showClearPersistedLink: {
type: Boolean,
default: true,
},
/**
* Allow marking a drawn winner as absent: removed from pemenang and excluded
* from the wheel (dikeluarkan daripada acara).
*/
allowMarkAbsent: {
type: Boolean,
default: true,
},
/** Show logo hub in the center (decorative); click anywhere on the wheel to spin. */
showCenterLogo: {
type: Boolean,
default: true,
},
/** Public URL for center logo (e.g. `/images/MyKoPKB-logo.png`). Empty hides image. */
centerLogoSrc: {
type: String,
default: '/images/MyKoPKB-logo.png',
},
/** When true, stretch to parent width (no 980px cap). */
fullWidth: {
type: Boolean,
default: false,
},
/**
* URL of a short spin sound (served from `public/`, e.g. `/audio/spin-wheel.mp3`).
* Empty string disables audio. Plays on user-triggered spin (click / Space).
*/
spinSoundSrc: {
type: String,
default: '/audio/spin-wheel.mp3',
},
/** Playback volume for `spinSoundSrc` (01). */
spinSoundVolume: {
type: Number,
default: 0.85,
},
/**
* When true, the spin sound loops until the wheel stops; then it is stopped.
* Use with a short mechanical/ticking loop; leave false for a one-shot whoosh.
*/
spinSoundLoop: {
type: Boolean,
default: false,
},
},
data: function () {
return {
/** Snapshot of { id, label } for the list UI (fixed order). */
participantPool: [],
/** Wheel pool: { id, label }[] */
remaining: [],
winnerHistory: [],
poolSize: 0,
rotationDeg: 0,
spinning: false,
lastWinner: null,
pendingRemoveIndex: null,
transitionMs: 9000,
/** Set when a spin ends; drives winner modal body. */
modalWinnerDisplay: null,
/** Participant ids eliminated as "tidak hadir" — not on wheel, not in pemenang. */
absentParticipantIds: [],
};
},
computed: {
wheelStyle: function () {
return {
transform: 'rotate(' + this.rotationDeg + 'deg)',
transitionDuration: this.spinning ? this.transitionMs + 'ms' : '0ms',
transitionTimingFunction: this.spinning
? 'cubic-bezier(0.12, 0.8, 0.12, 1)'
: 'linear',
};
},
/** Wider wheel column when `fullWidth` (demo / large layout). */
wheelColumnClass: function () {
if (!this.participantPool.length) {
return 'col-xs-12';
}
if (this.fullWidth) {
return 'col-sm-12 col-md-8';
}
return 'col-sm-12 col-md-7';
},
sideColumnClass: function () {
if (!this.participantPool.length) {
return '';
}
if (this.fullWidth) {
return 'col-sm-12 col-md-4';
}
return 'col-sm-12 col-md-5';
},
wheelCanSpin: function () {
return this.remaining.length >= 2 && !this.spinning;
},
wheelStageTabindex: function () {
return this.wheelCanSpin ? 0 : -1;
},
},
watch: {
items: {
immediate: true,
deep: true,
handler: function (val) {
if (!this.spinning) {
this.syncFromItems(val);
this.$nextTick(this.drawWheel);
}
},
},
remaining: function () {
this.$nextTick(this.drawWheel);
},
spinSoundSrc: function (val) {
if (!val) {
this.disposeSpinSoundAudio();
} else if (this._spinSoundAudio) {
this._spinSoundAudio.src = val;
}
},
spinSoundVolume: function (v) {
if (this._spinSoundAudio) {
this._spinSoundAudio.volume = this.clampSpinSoundVolume(v);
}
},
spinSoundLoop: function (loop) {
if (this._spinSoundAudio) {
this._spinSoundAudio.loop = !!loop;
}
},
},
mounted: function () {
window.addEventListener('resize', this.drawWheel);
window.addEventListener('keydown', this.onDocumentKeydown, true);
this.$nextTick(this.drawWheel);
},
beforeDestroy: function () {
window.removeEventListener('resize', this.drawWheel);
window.removeEventListener('keydown', this.onDocumentKeydown, true);
this.disposeSpinSoundAudio();
},
methods: {
clampSpinSoundVolume: function (v) {
var n = typeof v === 'number' ? v : parseFloat(v);
if (n !== n) {
return 0.85;
}
if (n < 0) {
return 0;
}
if (n > 1) {
return 1;
}
return n;
},
disposeSpinSoundAudio: function () {
if (!this._spinSoundAudio) {
return;
}
try {
this._spinSoundAudio.pause();
this._spinSoundAudio.src = '';
} catch (e) {
/* ignore */
}
this._spinSoundAudio = null;
},
ensureSpinSoundAudio: function () {
if (!this.spinSoundSrc || typeof Audio === 'undefined') {
return null;
}
if (!this._spinSoundAudio) {
this._spinSoundAudio = new Audio();
this._spinSoundAudio.preload = 'auto';
}
this._spinSoundAudio.src = this.spinSoundSrc;
this._spinSoundAudio.volume = this.clampSpinSoundVolume(this.spinSoundVolume);
this._spinSoundAudio.loop = !!this.spinSoundLoop;
return this._spinSoundAudio;
},
playSpinSound: function () {
var a = this.ensureSpinSoundAudio();
if (!a) {
return;
}
try {
a.currentTime = 0;
} catch (e) {
/* ignore */
}
var p = a.play();
if (p && typeof p.catch === 'function') {
p.catch(function () {
/* Autoplay policy or missing file — silent failure. */
});
}
},
stopSpinSoundAfterSpin: function () {
if (!this._spinSoundAudio || !this.spinSoundLoop) {
return;
}
try {
this._spinSoundAudio.loop = false;
this._spinSoundAudio.pause();
this._spinSoundAudio.currentTime = 0;
} catch (e) {
/* ignore */
}
},
isWinnerModalOpen: function () {
if (typeof window.$ === 'undefined') {
return false;
}
try {
return window.$('#roulette-winner-modal').hasClass('in');
} catch (e) {
return false;
}
},
onDocumentKeydown: function (e) {
if (!this.enableSpaceToSpin) {
return;
}
if (e.key !== ' ' && e.key !== 'Spacebar' && e.code !== 'Space') {
return;
}
if (e.repeat) {
return;
}
var el = e.target;
if (el) {
var tag = el.tagName && el.tagName.toLowerCase();
if (
tag === 'input' ||
tag === 'textarea' ||
tag === 'select' ||
(el.isContentEditable === true)
) {
return;
}
}
if (this.isWinnerModalOpen()) {
return;
}
if (this.spinning || this.remaining.length < 2) {
return;
}
e.preventDefault();
this.spin();
},
normalizeId: function (x) {
if (x === null || x === undefined) {
return '';
}
return String(x);
},
/**
* @returns {{ id: string, label: string }[]}
*/
normalizeItems: function (val) {
if (!val || !val.length) {
return [];
}
var out = [];
var seen = {};
for (var i = 0; i < val.length; i++) {
var row = val[i];
var id;
var label;
if (row != null && typeof row === 'object' && !Array.isArray(row)) {
id =
row.id !== undefined && row.id !== null
? this.normalizeId(row.id)
: 'legacy:' + i;
label = String(row.label != null ? row.label : '');
} else {
id = 'legacy:' + i;
label = String(row != null ? row : '');
}
if (!label.trim()) {
label = id.indexOf('legacy:') === 0 ? id : label;
}
if (seen[id]) {
continue;
}
seen[id] = true;
out.push({ id: id, label: label });
}
return out;
},
/**
* Merge persisted v2 game state with current items (same ids + new ids).
* Winners / absent kept; new ids join the wheel only.
*/
applyMergeFromPersisted: function (norm, persisted) {
if (!persisted || persisted.v !== 2) {
return false;
}
var normList = [];
var normSet = {};
var labelById = {};
for (var i = 0; i < norm.length; i++) {
var nid = this.normalizeId(norm[i].id);
var lab = norm[i].label;
normList.push({ id: nid, label: lab });
normSet[nid] = true;
labelById[nid] = lab;
}
var whRaw = persisted.winnerHistory || [];
var winnerHistory = [];
var winnerSeen = {};
for (var w = 0; w < whRaw.length; w++) {
var wr = whRaw[w];
var wid = this.normalizeId(wr.id != null ? wr.id : wr.idx);
if (!normSet[wid]) {
continue;
}
if (winnerSeen[wid]) {
return false;
}
winnerSeen[wid] = true;
winnerHistory.push({
id: wid,
round: typeof wr.round === 'number' ? wr.round : winnerHistory.length + 1,
label:
labelById[wid] != null
? labelById[wid]
: String(wr.label != null ? wr.label : ''),
});
}
var absentRaw = persisted.absentParticipantIds || persisted.absentParticipantIdxs || [];
var absentParticipantIds = [];
var absSeen = {};
for (var a = 0; a < absentRaw.length; a++) {
var aid = this.normalizeId(absentRaw[a]);
if (!normSet[aid]) {
continue;
}
if (absSeen[aid] || winnerSeen[aid]) {
return false;
}
absSeen[aid] = true;
absentParticipantIds.push(aid);
}
absentParticipantIds.sort();
for (var h = 0; h < winnerHistory.length; h++) {
winnerHistory[h].round = h + 1;
}
var remaining = [];
for (var r = 0; r < normList.length; r++) {
var rid = normList[r].id;
if (absSeen[rid]) {
continue;
}
if (winnerSeen[rid]) {
continue;
}
remaining.push({ id: rid, label: normList[r].label });
}
this.participantPool = normList.slice();
this.remaining = remaining;
this.winnerHistory = winnerHistory;
this.absentParticipantIds = absentParticipantIds;
this.poolSize = normList.length;
this.updateLastWinnerFromHistory();
this.rotationDeg = 0;
this.pendingRemoveIndex = null;
this.modalWinnerDisplay = null;
return true;
},
persistState: function () {
if (!this.persistKey || typeof localStorage === 'undefined') {
return;
}
var norm = this.normalizeItems(this.items);
if (!norm.length) {
return;
}
try {
localStorage.setItem(
this.persistKey,
JSON.stringify({
v: 2,
winnerHistory: this.winnerHistory,
remaining: this.remaining,
absentParticipantIds: this.absentParticipantIds,
})
);
} catch (e) {
// quota / private mode
}
},
clearPersistedState: function () {
if (this.persistKey && typeof localStorage !== 'undefined') {
try {
localStorage.removeItem(this.persistKey);
} catch (e) { }
}
if (typeof window.$ !== 'undefined') {
window.$('#roulette-winner-modal').modal('hide');
}
this.initFreshFromNormalized(
this.normalizeItems(this.items && this.items.length ? this.items : [])
);
this.$nextTick(this.drawWheel);
},
initFreshFromNormalized: function (norm) {
this.participantPool = [];
this.remaining = [];
this.winnerHistory = [];
this.absentParticipantIds = [];
this.lastWinner = null;
this.rotationDeg = 0;
this.pendingRemoveIndex = null;
this.modalWinnerDisplay = null;
if (!norm || !norm.length) {
this.poolSize = 0;
return;
}
for (var i = 0; i < norm.length; i++) {
var e = { id: norm[i].id, label: norm[i].label };
this.participantPool.push(e);
this.remaining.push({ id: e.id, label: e.label });
}
this.poolSize = this.remaining.length;
},
updateLastWinnerFromHistory: function () {
if (this.winnerHistory.length) {
this.lastWinner =
this.winnerHistory[this.winnerHistory.length - 1].label;
} else {
this.lastWinner = null;
}
},
syncFromItems: function (val) {
this.spinning = false;
this.pendingRemoveIndex = null;
this.modalWinnerDisplay = null;
var norm = this.normalizeItems(val);
if (!norm.length) {
this.participantPool = [];
this.remaining = [];
this.winnerHistory = [];
this.absentParticipantIds = [];
this.poolSize = 0;
this.lastWinner = null;
this.rotationDeg = 0;
if (this.persistKey && typeof localStorage !== 'undefined') {
try {
localStorage.removeItem(this.persistKey);
} catch (e) { }
}
return;
}
var raw;
var data = null;
if (this.persistKey && typeof localStorage !== 'undefined') {
try {
raw = localStorage.getItem(this.persistKey);
if (raw) {
data = JSON.parse(raw);
}
} catch (e) {
data = null;
}
}
if (data && data.v === 2 && this.applyMergeFromPersisted(norm, data)) {
this.persistState();
return;
}
if (this.persistKey && typeof localStorage !== 'undefined') {
try {
localStorage.removeItem(this.persistKey);
} catch (e2) { }
}
this.initFreshFromNormalized(norm);
this.persistState();
},
isStillInPool: function (id) {
var sid = this.normalizeId(id);
for (var i = 0; i < this.remaining.length; i++) {
if (this.remaining[i].id === sid) {
return true;
}
}
return false;
},
winRoundForParticipant: function (id) {
var sid = this.normalizeId(id);
for (var i = 0; i < this.winnerHistory.length; i++) {
if (this.winnerHistory[i].id === sid) {
return this.winnerHistory[i].round;
}
}
return null;
},
isAbsentParticipant: function (id) {
var sid = this.normalizeId(id);
return this.absentParticipantIds.indexOf(sid) >= 0;
},
participantRowClass: function (p) {
if (this.isStillInPool(p.id)) {
return 'participant-row--active';
}
if (this.isAbsentParticipant(p.id)) {
return 'participant-row--absent';
}
if (this.winRoundForParticipant(p.id) !== null) {
return 'participant-row--won';
}
return '';
},
participantBadge: function (p) {
if (this.isStillInPool(p.id)) {
return 'Dalam roda';
}
if (this.isAbsentParticipant(p.id)) {
return 'Tidak hadir (dikeluarkan)';
}
var r = this.winRoundForParticipant(p.id);
if (r !== null) {
return 'Pemenang pusingan ' + r;
}
return '—';
},
spin: function () {
var n = this.remaining.length;
if (n < 2 || this.spinning) {
return;
}
var winIndex = Math.floor(Math.random() * n);
this.pendingRemoveIndex = winIndex;
var sliceDeg = 360 / n;
// Canvas: segment i is centered at (winIndex + 0.5) * sliceDeg clockwise from top.
// CSS rotate(R) turns the wheel CW. The rim point now at the pointer came from
// clockwise-from-top angle T where T + R ≡ 360 (mod 360), so T ≡ 360 - R.
// To land segment winIndex under the arrow: T = (winIndex + 0.5) * sliceDeg, hence
// R mod 360 === 360 - T (not T).
var segmentCenterCW = winIndex * sliceDeg + sliceDeg / 2;
var targetMod = (360 - segmentCenterCW + 360) % 360;
var currentNorm = ((this.rotationDeg % 360) + 360) % 360;
var needed = targetMod - currentNorm;
if (needed < 0) {
needed += 360;
}
this.transitionMs = this.durationMs;
this.spinning = true;
this.lastWinner = null;
this.playSpinSound();
var extra = 360 * this.minSpins;
this.rotationDeg = this.rotationDeg + extra + needed;
},
onSpinEnd: function (e) {
if (!this.spinning || e.propertyName !== 'transform') {
return;
}
this.stopSpinSoundAfterSpin();
this.spinning = false;
if (this.pendingRemoveIndex === null) {
return;
}
var entry = this.remaining[this.pendingRemoveIndex];
var name = entry.label;
this.lastWinner = name;
var round = this.winnerHistory.length + 1;
this.winnerHistory.push({
id: entry.id,
label: entry.label,
round: round,
});
this.remaining.splice(this.pendingRemoveIndex, 1);
this.pendingRemoveIndex = null;
var remainingLabels = this.remaining.map(function (x) {
return x.label;
});
var pid = entry.id;
var idxNum = parseInt(pid, 10);
this.$emit('winner', {
name: name,
participantId: pid,
participantIndex: idxNum === idxNum ? idxNum : pid,
round: round,
remaining: remainingLabels,
});
this.$emit('eliminate', {
name: name,
participantId: pid,
participantIndex: idxNum === idxNum ? idxNum : pid,
round: round,
remaining: remainingLabels,
});
if (this.showWinnerModal) {
this.modalWinnerDisplay = {
label: name,
round: round,
historyIndex: this.winnerHistory.length - 1,
};
var vm = this;
this.$nextTick(function () {
vm.openWinnerModal();
});
}
this.persistState();
},
hideWinnerModal: function () {
if (typeof window.$ !== 'undefined') {
window.$('#roulette-winner-modal').modal('hide');
}
},
onWinnerAbsentFromModal: function () {
if (
this.spinning ||
!this.modalWinnerDisplay ||
this.modalWinnerDisplay.historyIndex === undefined
) {
return;
}
var hi = this.modalWinnerDisplay.historyIndex;
this.hideWinnerModal();
var vm = this;
this.$nextTick(function () {
vm.markWinnerAbsentAtHistoryIndex(hi);
});
},
/**
* Pemenang tidak hadir: buang daripada senarai pemenang; tidak dimasukkan semula ke roda.
*/
markWinnerAbsentAtHistoryIndex: function (historyIndex) {
if (this.spinning) {
return;
}
if (historyIndex < 0 || historyIndex >= this.winnerHistory.length) {
return;
}
this.hideWinnerModal();
var w = this.winnerHistory[historyIndex];
if (!w) {
return;
}
if (this.isAbsentParticipant(w.id)) {
return;
}
var roundRemoved = w.round;
this.winnerHistory.splice(historyIndex, 1);
for (var r = 0; r < this.winnerHistory.length; r++) {
this.winnerHistory[r].round = r + 1;
}
var wid = this.normalizeId(w.id);
var label = w.label;
for (var pi = 0; pi < this.participantPool.length; pi++) {
if (this.participantPool[pi].id === wid) {
label = this.participantPool[pi].label;
break;
}
}
if (this.absentParticipantIds.indexOf(wid) < 0) {
this.absentParticipantIds.push(wid);
this.absentParticipantIds.sort();
}
this.updateLastWinnerFromHistory();
this.rotationDeg = 0;
this.modalWinnerDisplay = null;
var remainingLabels = this.remaining.map(function (x) {
return x.label;
});
var widx = parseInt(wid, 10);
this.$emit('winner-absent', {
name: label,
participantId: wid,
participantIndex: widx === widx ? widx : wid,
roundRemoved: roundRemoved,
remaining: remainingLabels,
eliminated: true,
});
this.persistState();
this.$nextTick(this.drawWheel);
},
openWinnerModal: function () {
if (typeof window.$ === 'undefined') {
return;
}
if (this.util && typeof this.util.showModal === 'function') {
this.util.showModal('#roulette-winner-modal');
} else {
window.$('#roulette-winner-modal').modal('show');
}
},
drawWheel: function () {
var canvas = this.$refs.wheelCanvas;
if (!canvas) {
return;
}
var wrap = canvas.parentElement;
var maxPx = this.fullWidth ? 880 : 600;
var size = Math.min(wrap.clientWidth || 320, maxPx);
var dpr = window.devicePixelRatio || 1;
canvas.width = size * dpr;
canvas.height = size * dpr;
canvas.style.width = size + 'px';
canvas.style.height = size + 'px';
var ctx = canvas.getContext('2d');
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
var cx = size / 2;
var cy = size / 2;
var r = size / 2 - 4;
ctx.clearRect(0, 0, size, size);
var labels = this.remaining.map(function (x) {
return x.label;
});
var n = labels.length;
if (n === 0) {
ctx.beginPath();
ctx.arc(cx, cy, r, 0, Math.PI * 2);
ctx.fillStyle = '#e8e8e8';
ctx.fill();
return;
}
var slice = (Math.PI * 2) / n;
var palette = [
'#e74c3c', '#3498db', '#2ecc71', '#f39c12', '#9b59b6',
'#1abc9c', '#e67e22', '#34495e',
];
for (var i = 0; i < n; i++) {
var start = -Math.PI / 2 + i * slice;
ctx.beginPath();
ctx.moveTo(cx, cy);
ctx.arc(cx, cy, r, start, start + slice);
ctx.closePath();
ctx.fillStyle = palette[i % palette.length];
ctx.fill();
ctx.strokeStyle = '#fff';
ctx.lineWidth = 2;
ctx.stroke();
var mid = start + slice / 2;
ctx.save();
ctx.translate(cx, cy);
ctx.rotate(mid);
ctx.fillStyle = '#fff';
ctx.font = 'bold ' + Math.max(11, Math.floor(size / 28)) + 'px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
var text = String(labels[i]);
if (text.length > 14) {
text = text.slice(0, 12) + '…';
}
ctx.fillText(text, r * 0.62, 0);
ctx.restore();
}
ctx.beginPath();
ctx.arc(cx, cy, 16, 0, Math.PI * 2);
ctx.fillStyle = '#fff';
ctx.fill();
ctx.strokeStyle = '#333';
ctx.lineWidth = 2;
ctx.stroke();
},
},
};
</script>
<style scoped>
.roulette-elimination {
max-width: 980px;
margin: 0 auto;
text-align: center;
}
.roulette-elimination--full-width {
max-width: none;
width: 100%;
margin-left: 0;
margin-right: 0;
}
.roulette-elimination--full-width .wheel-stage {
max-width: min(100%, 880px);
}
.roulette-main-row {
align-items: flex-start;
}
.roulette-wheel-col {
text-align: center;
}
.roulette-side-col {
text-align: left;
margin-top: 1.25rem;
}
@media (min-width: 992px) {
.roulette-side-col {
margin-top: 0;
padding-left: 8px;
}
}
.wheel-stage {
position: relative;
width: 100%;
max-width: 600px;
margin: 0 auto 1rem;
outline: none;
user-select: none;
}
.wheel-stage--can-spin {
cursor: pointer;
}
.wheel-stage--can-spin:focus {
box-shadow: 0 0 0 3px rgba(51, 122, 183, 0.45);
border-radius: 4px;
}
.wheel-stage:not(.wheel-stage--can-spin) {
cursor: not-allowed;
}
.pointer {
position: absolute;
top: -8px;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 17px solid transparent;
border-right: 17px solid transparent;
border-top: 26px solid #222;
z-index: 2;
filter: drop-shadow(0 2px 2px rgba(0, 0, 0, 0.25));
}
.wheel-wrap {
position: relative;
z-index: 1;
width: 100%;
border-radius: 50%;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
transition-property: transform;
will-change: transform;
}
.wheel-center-logo {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 3;
width: 22%;
min-width: 72px;
max-width: 150px;
padding: 6px;
border: 3px solid rgba(255, 255, 255, 0.95);
border-radius: 50%;
background: #fff;
box-shadow: 0 4px 18px rgba(0, 0, 0, 0.22);
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.15s ease, box-shadow 0.15s ease, opacity 0.15s ease;
}
.wheel-stage--can-spin:hover .wheel-center-logo {
transform: translate(-50%, -50%) scale(1.06);
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.28);
}
.wheel-stage--can-spin:active .wheel-center-logo {
transform: translate(-50%, -50%) scale(0.97);
}
.wheel-stage:not(.wheel-stage--can-spin) .wheel-center-logo {
opacity: 0.5;
}
.wheel-center-logo-img {
width: 100%;
height: auto;
max-height: 100%;
object-fit: contain;
border-radius: 50%;
pointer-events: none;
user-select: none;
}
.wheel-canvas {
display: block;
width: 100%;
height: auto;
border-radius: 50%;
}
.roulette-click-hint {
margin-top: 0.35rem;
margin-bottom: 0;
}
.roulette-space-hint {
margin-top: 0.25rem;
margin-bottom: 0;
}
.roulette-space-hint kbd {
display: inline-block;
padding: 1px 6px;
font-size: 11px;
line-height: 1.4;
color: #333;
background-color: #f7f7f7;
border: 1px solid #ccc;
border-radius: 3px;
box-shadow: 0 1px 0 rgba(0, 0, 0, 0.08);
}
.roulette-persist-clear {
margin-top: 0.35rem;
margin-bottom: 0;
}
.hint {
font-size: 0.9rem;
margin-top: 0.5rem;
}
.last-winner {
margin-top: 0.5rem;
}
.remaining-count {
font-size: 20px;
color: #666;
margin-top: 0.25rem;
}
.roulette-side-col .roulette-panel {
margin-bottom: 15px;
}
.roulette-side-col .roulette-panel:last-child {
margin-bottom: 0;
}
.participant-list--scroll {
max-height: min(42vh, 340px);
overflow-y: auto;
}
.winner-ol--scroll {
max-height: min(28vh, 220px);
overflow-y: auto;
}
.roulette-panel .panel-heading {
font-size: 14px;
font-weight: 600;
}
.participant-list {
margin-bottom: 0;
}
.participant-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
font-size: 13px;
}
.participant-row--active {
background-color: #f0fdf4;
border-left: 3px solid #22c55e;
}
.participant-row--won {
color: #6b7280;
background-color: #f9fafb;
}
.participant-row--absent {
color: #92400e;
background-color: #fffbeb;
border-left: 3px solid #f59e0b;
}
.participant-name {
flex: 1;
min-width: 0;
word-break: break-word;
}
.participant-badge {
font-size: 11px;
white-space: nowrap;
color: #6b7280;
}
.participant-row--active .participant-badge {
color: #15803d;
font-weight: 600;
}
.winner-ol {
margin: 0;
padding: 10px 15px 10px 28px;
font-size: 13px;
}
.winner-li {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 6px;
margin-bottom: 8px;
}
.winner-li-main {
flex: 1;
min-width: 0;
}
.winner-absent-btn {
white-space: nowrap;
padding: 0 4px;
flex-shrink: 0;
}
.winner-label {
font-weight: 600;
}
.winner-meta {
margin-left: 8px;
font-size: 12px;
}
.winner-empty {
margin: 0;
font-size: 13px;
}
/* Hall / projection: wide dialog, very large type */
.roulette-winner-modal__dialog {
width: 96%;
max-width: min(96vw, 1320px);
margin: 12px auto;
}
@media (min-width: 768px) {
.roulette-winner-modal__dialog {
width: 94%;
max-width: min(94vw, 1400px);
}
}
@media (min-width: 1200px) {
.roulette-winner-modal__dialog {
max-width: 1440px;
}
}
.roulette-winner-modal .modal-content {
border-radius: 16px;
overflow: hidden;
border-width: 2px;
}
.roulette-winner-modal .modal-header {
padding: clamp(20px, 3vw, 36px) clamp(18px, 3vw, 40px);
position: relative;
}
.roulette-winner-modal .modal-header .close {
font-size: clamp(2rem, 4vw, 3.25rem);
font-weight: 300;
line-height: 1;
padding: 8px 12px;
margin-top: -4px;
opacity: 0.55;
}
.roulette-winner-modal .modal-body {
padding: clamp(32px, 5vw, 72px) clamp(20px, 4vw, 48px);
}
.roulette-winner-modal .modal-footer {
padding: clamp(16px, 2.5vw, 28px) clamp(18px, 3vw, 40px);
}
.roulette-winner-modal .modal-footer .btn {
font-size: clamp(1.15rem, 2.2vw, 1.65rem);
font-weight: 700;
padding: clamp(10px, 1.5vw, 16px) clamp(18px, 2.5vw, 28px);
}
.roulette-winner-modal__title {
font-weight: 800;
font-size: clamp(2.25rem, 5.5vw, 5rem);
line-height: 1.15;
margin: 0;
padding-right: 2.5rem;
}
.roulette-winner-modal-round {
font-size: clamp(1.5rem, 3.5vw, 3.25rem);
margin-bottom: clamp(16px, 2.5vw, 28px);
font-weight: 700;
/* Stronger than .text-muted for projection / rear seats */
color: #4b5563 !important;
}
.roulette-winner-modal-name {
font-size: clamp(3rem, 12vw, 8.5rem);
font-weight: 800;
line-height: 1.12;
margin: 0;
word-break: break-word;
letter-spacing: -0.03em;
text-shadow: 0 2px 0 rgba(0, 0, 0, 0.06);
}
.roulette-winner-absent-hint {
margin-top: 12px;
}
</style>