1298 lines
31 KiB
Vue
1298 lines
31 KiB
Vue
<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 & 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> (disingkirkan daripada pusingan seterusnya)
|
||
</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" 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">×</span>
|
||
</button>
|
||
<h4 class="modal-title" id="roulette-winner-title">
|
||
Tahniah
|
||
</h4>
|
||
</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` (0–1). */
|
||
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;
|
||
}
|
||
|
||
.roulette-winner-modal .modal-title {
|
||
font-weight: 700;
|
||
}
|
||
|
||
.roulette-winner-modal-round {
|
||
font-size: 14px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.roulette-winner-modal-name {
|
||
font-size: 1.75rem;
|
||
font-weight: 700;
|
||
line-height: 1.3;
|
||
margin: 0;
|
||
word-break: break-word;
|
||
}
|
||
|
||
.roulette-winner-absent-hint {
|
||
margin-top: 12px;
|
||
}
|
||
</style>
|