import { t } from './i18n.js'; import { DISC_COLORS, PERSONALITY_DATA, getPersonality } from './personalities.js'; const WHEEL_TYPES = ['D','Di','DI','Id','I','Is','IS','Si','S','Sc','SC','Cs','C','Cd','CD','Dc']; const ADJACENT = { D: ['I','C'], I: ['D','S'], S: ['I','C'], C: ['S','D'] }; // --- Scoring --- export function calculateScores(answers) { const raw = { D: 0, I: 0, S: 0, C: 0 }; const counts = { D: 0, I: 0, S: 0, C: 0 }; for (const [id, value] of Object.entries(answers)) { const idx = parseInt(id) - 1; const dims = ['D','I','S','C']; const dim = dims[idx % 4]; raw[dim] += value; counts[dim]++; } const scores = {}; for (const dim of ['D','I','S','C']) { scores[dim] = counts[dim] > 0 ? Math.round((raw[dim] / (counts[dim] * 5)) * 100) : 0; } return scores; } export function determineType(scores) { const sorted = Object.entries(scores).sort((a, b) => b[1] - a[1]); const [first, fScore] = sorted[0]; const [second, sScore] = sorted[1]; const diff = fScore - sScore; const adjacent = ADJACENT[first]?.includes(second); if (!adjacent || diff > 25) return first; // Equal blends must follow clockwise DISC order: DI, IS, SC, CD const BLEND_ORDER = { D: { I: 'DI', C: 'CD' }, I: { D: 'DI', S: 'IS' }, S: { I: 'IS', C: 'SC' }, C: { S: 'SC', D: 'CD' } }; if (diff <= 8) return BLEND_ORDER[first]?.[second] || first + second; // Dominant blend: primary + lowercase secondary, respecting wheel order const DOM_ORDER = { D: { I: 'Di', C: 'Dc' }, I: { D: 'Id', S: 'Is' }, S: { I: 'Si', C: 'Sc' }, C: { S: 'Cs', D: 'Cd' } }; return DOM_ORDER[first]?.[second] || first + second.toLowerCase(); } // --- Encoding --- export function encodeResult(scores) { const str = `${scores.D},${scores.I},${scores.S},${scores.C}`; return btoa(str).replace(/=+$/, ''); } export function decodeResult(code) { try { const padded = code + '==='.slice(0, (4 - code.length % 4) % 4); const parts = atob(padded).split(',').map(Number); if (parts.length === 4 && parts.every(n => !isNaN(n) && n >= 0 && n <= 100)) { return { D: parts[0], I: parts[1], S: parts[2], C: parts[3] }; } } catch {} return null; } export function extractCode(input) { input = (input || '').trim(); try { const url = new URL(input); return url.searchParams.get('r') || url.searchParams.get('p') || input; } catch { return input; } } export function encodeProfiles(profiles) { const parts = profiles.map(p => (p.name ? p.name + ':' : '') + p.code); const bytes = new TextEncoder().encode(parts.join('|')); return btoa(String.fromCharCode(...bytes)).replace(/=+$/, ''); } export function decodeProfiles(encoded) { try { const padded = encoded + '==='.slice(0, (4 - encoded.length % 4) % 4); const binary = atob(padded); const bytes = Uint8Array.from(binary, c => c.charCodeAt(0)); const str = new TextDecoder().decode(bytes); return str.split('|').map(part => { const colonIdx = part.indexOf(':'); if (colonIdx > 0 && colonIdx < part.length - 1) { return { name: part.slice(0, colonIdx), code: part.slice(colonIdx + 1) }; } return { name: '', code: part }; }); } catch { return []; } } // --- Storage --- export function saveResult(scores) { localStorage.setItem('disc_scores', JSON.stringify(scores)); localStorage.setItem('disc_code', encodeResult(scores)); } export function loadResult() { try { return JSON.parse(localStorage.getItem('disc_scores')); } catch { return null; } } // --- Canvas helpers --- function initCanvas(canvas, size) { const dpr = window.devicePixelRatio || 1; canvas.width = size * dpr; canvas.height = size * dpr; canvas.style.width = size + 'px'; canvas.style.height = size + 'px'; const ctx = canvas.getContext('2d'); ctx.scale(dpr, dpr); return { ctx, w: size, h: size }; } // --- Axes --- const AXES = [ { id: 'assertive', leftKey: 'axis.accommodating', rightKey: 'axis.assertive', colorL: DISC_COLORS.S, colorR: DISC_COLORS.D, calc: s => (s.D * 1.2 + s.I * 0.3) - (s.S * 1.2 + s.C * 0.3) }, { id: 'expressive', leftKey: 'axis.reserved', rightKey: 'axis.expressive', colorL: DISC_COLORS.C, colorR: DISC_COLORS.I, calc: s => (s.I * 1.3 + s.D * 0.2) - (s.C * 1.3 + s.S * 0.2) }, { id: 'pace', leftKey: 'axis.deliberate', rightKey: 'axis.fast_paced', colorL: DISC_COLORS.S, colorR: DISC_COLORS.D, calc: s => (s.D * 1.0 + s.I * 0.5) - (s.S * 1.0 + s.C * 0.5) }, { id: 'focus', leftKey: 'axis.task_focused', rightKey: 'axis.people_focused',colorL: DISC_COLORS.C, colorR: DISC_COLORS.I, calc: s => (s.I * 1.2 + s.S * 0.3) - (s.C * 1.2 + s.D * 0.3) }, { id: 'risk', leftKey: 'axis.cautious', rightKey: 'axis.risk_taking', colorL: DISC_COLORS.C, colorR: DISC_COLORS.D, calc: s => (s.D * 1.5 + s.I * 0.5) - (s.C * 1.5 + s.S * 0.5) }, { id: 'structure', leftKey: 'axis.flexible', rightKey: 'axis.structured', colorL: DISC_COLORS.I, colorR: DISC_COLORS.C, calc: s => (s.C * 1.3 + s.S * 0.2) - (s.I * 1.3 + s.D * 0.2) } ]; export function computeAxes(scores) { return AXES.map(a => { const raw = a.calc(scores); const clamped = Math.max(-100, Math.min(100, raw)); return { ...a, value: clamped }; }); } // --- Overlap offset helpers --- function applyAxisOverlapOffset(items, threshold) { threshold = threshold || 3; if (items.length < 2) return; const sorted = items.slice().sort((a, b) => a.pct - b.pct); const groups = [[sorted[0]]]; for (let i = 1; i < sorted.length; i++) { const last = groups[groups.length - 1]; if (Math.abs(sorted[i].pct - last[last.length - 1].pct) < threshold) { last.push(sorted[i]); } else { groups.push([sorted[i]]); } } for (const group of groups) { if (group.length <= 1) continue; const step = 9; const total = (group.length - 1) * step; group.forEach((item, i) => { item.yOffset = -total / 2 + i * step; }); } } function applyCanvasDotOverlap(dots, dotR) { const threshold = dotR * 1.8; const offset = dotR * 0.66; const visited = new Set(); const groups = []; for (let i = 0; i < dots.length; i++) { if (visited.has(i)) continue; const group = [i]; visited.add(i); for (let j = i + 1; j < dots.length; j++) { if (visited.has(j)) continue; const dx = dots[j].x - dots[i].x, dy = dots[j].y - dots[i].y; if (Math.sqrt(dx * dx + dy * dy) < threshold) { group.push(j); visited.add(j); } } if (group.length > 1) groups.push(group); } for (const group of groups) { const n = group.length; const gx = group.reduce((s, i) => s + dots[i].x, 0) / n; const gy = group.reduce((s, i) => s + dots[i].y, 0) / n; group.forEach((idx, gi) => { const angle = (2 * Math.PI * gi) / n - Math.PI / 2; dots[idx].x = gx + Math.cos(angle) * offset; dots[idx].y = gy + Math.sin(angle) * offset; }); } } export function renderAxisSliders(container, scores) { const axes = computeAxes(scores); container.innerHTML = axes.map(a => { const pct = ((a.value + 100) / 200) * 100; return `
${t(a.leftKey)}${t(a.rightKey)}
`; }).join(''); } export function renderMultiAxisSliders(container, profiles) { const colors = ['#ffffff','#9966ff','#ff6b9d','#4ecdc4','#ffe66d','#ff8a5c','#a8e6cf','#ff4757']; const allAxes = profiles.map(p => computeAxes(p.scores)); container.innerHTML = AXES.map((a, ai) => { const items = profiles.map((p, pi) => ({ pct: ((allAxes[pi][ai].value + 100) / 200) * 100, pi, profile: p, yOffset: 0 })); applyAxisOverlapOffset(items); const thumbs = items.map(it => { const p = it.profile; const initials = (p.name || '?').slice(0, 2).toUpperCase(); const pers = getPersonality(p.type); const href = `results.html?r=${encodeResult(p.scores)}${p.name ? '&name=' + encodeURIComponent(p.name) : ''}`; const topOff = it.yOffset ? `top:calc(50% + ${it.yOffset}px);` : ''; return `${initials}`; }).join(''); return `
${t(a.leftKey)}${t(a.rightKey)}
${thumbs}
`; }).join(''); attachThumbTooltips(container); } export function renderMultiScoreAxes(container, profiles) { const colors = ['#ffffff','#9966ff','#ff6b9d','#4ecdc4','#ffe66d','#ff8a5c','#a8e6cf','#ff4757']; const dims = [ { key: 'D', color: DISC_COLORS.D }, { key: 'I', color: DISC_COLORS.I }, { key: 'S', color: DISC_COLORS.S }, { key: 'C', color: DISC_COLORS.C } ]; container.innerHTML = dims.map(d => { const values = profiles.map(p => p.scores[d.key]); const min = Math.min(...values); const max = Math.max(...values); const range = max - min || 1; const items = profiles.map((p, pi) => ({ pct: 15 + ((p.scores[d.key] - min) / range) * 70, pi, profile: p, yOffset: 0 })); applyAxisOverlapOffset(items); const thumbs = items.map(it => { const p = it.profile; const initials = (p.name || '?').slice(0, 2).toUpperCase(); const pers = getPersonality(p.type); const href = `results.html?r=${encodeResult(p.scores)}${p.name ? '&name=' + encodeURIComponent(p.name) : ''}`; const topOff = it.yOffset ? `top:calc(50% + ${it.yOffset}px);` : ''; return `${initials}`; }).join(''); return `
${min}%${t('dim.' + d.key)}${max}%
${thumbs}
`; }).join(''); attachThumbTooltips(container); } function attachThumbTooltips(container) { const tip = getTooltip(); container.addEventListener('mouseenter', e => { const el = e.target.closest('.axis-thumb-label[data-tip-name]'); if (!el) return; tip.innerHTML = `${el.dataset.tipName}
${el.dataset.tipType} — ${el.dataset.tipTypeName}`; tip.style.display = 'block'; const rect = el.getBoundingClientRect(); tip.style.left = (rect.right + window.scrollX + 8) + 'px'; tip.style.top = (rect.top + window.scrollY - 4) + 'px'; }, true); container.addEventListener('mouseleave', e => { const el = e.target.closest('.axis-thumb-label[data-tip-name]'); if (el) tip.style.display = 'none'; }, true); } // --- Diamond Chart --- export function drawDiamondChart(canvas, scores, scores2 = null, labels = {}) { const { ctx, w, h } = initCanvas(canvas, 380); const cx = w / 2, cy = h / 2, r = 140; ctx.clearRect(0, 0, w, h); // Grid for (let i = 1; i <= 4; i++) { const gr = (r / 4) * i; ctx.beginPath(); ctx.moveTo(cx, cy - gr); ctx.lineTo(cx + gr, cy); ctx.lineTo(cx, cy + gr); ctx.lineTo(cx - gr, cy); ctx.closePath(); ctx.strokeStyle = `rgba(255,255,255,${i === 4 ? 0.3 : 0.1})`; ctx.stroke(); } // Labels ctx.font = '600 12px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = DISC_COLORS.D; ctx.fillText(t('chart.dominance'), cx, cy - r - 10); ctx.fillStyle = DISC_COLORS.I; ctx.fillText(t('chart.influence'), cx + r + 10, cy + 4); ctx.fillStyle = DISC_COLORS.S; ctx.fillText(t('chart.steadiness'), cx, cy + r + 16); ctx.fillStyle = DISC_COLORS.C; ctx.fillText(t('chart.conscientiousness'), cx - r - 10, cy + 4); function diamondPts(sc) { // Normalize from midpoint 50 to spread the shape const norm = v => 0.5 + (v - 50) / 100; return [ [cx, cy - norm(sc.D) * r], [cx + norm(sc.I) * r, cy], [cx, cy + norm(sc.S) * r], [cx - norm(sc.C) * r, cy] ]; } function plotShape(sc, color, alpha) { const pts = diamondPts(sc); ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < pts.length; i++) ctx.lineTo(pts[i][0], pts[i][1]); ctx.closePath(); ctx.fillStyle = color.replace(')', `,${alpha})`).replace('rgb', 'rgba'); ctx.globalAlpha = alpha; ctx.fillStyle = color; ctx.fill(); ctx.globalAlpha = 1; ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke(); return pts; } function plotDot(sc, color, size, label) { const pts = diamondPts(sc); const px = pts.reduce((a, p) => a + p[0], 0) / 4; const py = pts.reduce((a, p) => a + p[1], 0) / 4; ctx.beginPath(); ctx.arc(px, py, size, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); if (label) { ctx.font = '600 10px Inter, system-ui, sans-serif'; ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.fillText(label, px + size + 4, py - 2); } } if (scores2) { plotShape(scores2, '#9966ff', 0.15); plotDot(scores2, '#9966ff', 5, labels.b); } plotShape(scores, '#ffffff', 0.2); plotDot(scores, '#ffffff', 6, labels.a); } export function drawMultiDiamond(canvas, profiles) { const colors = ['#ffffff','#9966ff','#ff6b9d','#4ecdc4','#ffe66d','#ff8a5c','#a8e6cf','#ff4757']; const { ctx, w, h } = initCanvas(canvas, 380); const cx = w / 2, cy = h / 2, r = 140; ctx.clearRect(0, 0, w, h); for (let i = 1; i <= 4; i++) { const gr = (r / 4) * i; ctx.beginPath(); ctx.moveTo(cx, cy - gr); ctx.lineTo(cx + gr, cy); ctx.lineTo(cx, cy + gr); ctx.lineTo(cx - gr, cy); ctx.closePath(); ctx.strokeStyle = `rgba(255,255,255,${i === 4 ? 0.3 : 0.1})`; ctx.stroke(); } ctx.font = '600 12px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = DISC_COLORS.D; ctx.fillText(t('chart.dominance'), cx, cy - r - 10); ctx.fillStyle = DISC_COLORS.I; ctx.fillText(t('chart.influence'), cx + r + 10, cy + 4); ctx.fillStyle = DISC_COLORS.S; ctx.fillText(t('chart.steadiness'), cx, cy + r + 16); ctx.fillStyle = DISC_COLORS.C; ctx.fillText(t('chart.conscientiousness'), cx - r - 10, cy + 4); profiles.forEach((p, idx) => { const color = colors[idx % colors.length]; const sc = p.scores; const norm = v => 0.5 + (v - 50) / 100; const pts = [ [cx, cy - norm(sc.D) * r], [cx + norm(sc.I) * r, cy], [cx, cy + norm(sc.S) * r], [cx - norm(sc.C) * r, cy] ]; ctx.beginPath(); ctx.moveTo(pts[0][0], pts[0][1]); for (let i = 1; i < 4; i++) ctx.lineTo(pts[i][0], pts[i][1]); ctx.closePath(); ctx.globalAlpha = 0.15; ctx.fillStyle = color; ctx.fill(); ctx.globalAlpha = 1; ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.stroke(); const px = pts.reduce((a, pt) => a + pt[0], 0) / 4; const py = pts.reduce((a, pt) => a + pt[1], 0) / 4; ctx.beginPath(); ctx.arc(px, py, 5, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); if (p.name) { ctx.font = '600 10px Inter, system-ui, sans-serif'; ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.fillText(p.name, px + 8, py - 4); } }); } // --- Axes Plot --- export function drawAxesPlot(canvas, scores, scores2 = null, labels = {}) { const { ctx, w, h } = initCanvas(canvas, 380); const cx = w / 2, cy = h / 2, r = 140; ctx.clearRect(0, 0, w, h); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.beginPath(); ctx.moveTo(cx - r, cy); ctx.lineTo(cx + r, cy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy + r); ctx.stroke(); ctx.font = '600 11px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.fillStyle = DISC_COLORS.D; ctx.fillText(t('chart.dominant'), cx, cy - r - 8); ctx.fillStyle = DISC_COLORS.S; ctx.fillText(t('chart.supportive'), cx, cy + r + 16); ctx.fillStyle = DISC_COLORS.C; ctx.fillText(t('chart.pragmatic'), cx - r - 8, cy + 4); ctx.fillStyle = DISC_COLORS.I; ctx.fillText(t('chart.optimistic'), cx + r + 8, cy + 4); function plotPoint(sc, color, size, label) { // Normalize from midpoint 50, apply power curve to spread clustered values const rawX = (sc.I - 50) / 50 - (sc.C - 50) / 50; const rawY = (sc.D - 50) / 50 - (sc.S - 50) / 50; const pow = 0.5; const sx = Math.sign(rawX) * Math.pow(Math.abs(rawX), pow); const sy = Math.sign(rawY) * Math.pow(Math.abs(rawY), pow); const x = cx + Math.max(-1, Math.min(1, sx)) * r; const y = cy - Math.max(-1, Math.min(1, sy)) * r; ctx.beginPath(); ctx.arc(x, y, size, 0, Math.PI * 2); ctx.fillStyle = color; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.3)'; ctx.lineWidth = 1; ctx.stroke(); if (label) { ctx.font = '600 10px Inter, system-ui, sans-serif'; ctx.fillStyle = color; ctx.textAlign = 'left'; ctx.fillText(label, x + size + 4, y - 2); } } if (scores2) plotPoint(scores2, '#9966ff', 6, labels.b); plotPoint(scores, '#ffffff', 7, labels.a); } export function drawMultiAxesPlot(canvas, profiles, size) { const colors = ['#ffffff','#9966ff','#ff6b9d','#4ecdc4','#ffe66d','#ff8a5c','#a8e6cf','#ff4757']; size = size || 380; const { ctx, w, h } = initCanvas(canvas, size); const cx = w / 2, cy = h / 2, r = size * 0.37; ctx.clearRect(0, 0, w, h); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.beginPath(); ctx.moveTo(cx - r, cy); ctx.lineTo(cx + r, cy); ctx.stroke(); ctx.beginPath(); ctx.moveTo(cx, cy - r); ctx.lineTo(cx, cy + r); ctx.stroke(); const fs = Math.max(9, size * 0.029); ctx.font = `600 ${fs}px Inter, system-ui, sans-serif`; ctx.textAlign = 'center'; ctx.fillStyle = DISC_COLORS.D; ctx.fillText(t('chart.dominant'), cx, cy - r - 8); ctx.fillStyle = DISC_COLORS.S; ctx.fillText(t('chart.supportive'), cx, cy + r + 16); ctx.fillStyle = DISC_COLORS.C; ctx.fillText(t('chart.pragmatic'), cx - r - 8, cy + 4); ctx.fillStyle = DISC_COLORS.I; ctx.fillText(t('chart.optimistic'), cx + r + 8, cy + 4); const dotR = Math.max(9, size * 0.028); const dots = profiles.map((p, idx) => { const color = colors[idx % colors.length]; const sc = p.scores; const rawX = (sc.I - 50) / 50 - (sc.C - 50) / 50; const rawY = (sc.D - 50) / 50 - (sc.S - 50) / 50; const pow = 0.5; const sx = Math.sign(rawX) * Math.pow(Math.abs(rawX), pow); const sy = Math.sign(rawY) * Math.pow(Math.abs(rawY), pow); return { x: cx + Math.max(-1, Math.min(1, sx)) * r, y: cy - Math.max(-1, Math.min(1, sy)) * r, r: dotR, color, profile: p }; }); applyCanvasDotOverlap(dots, dotR); dots.forEach(d => { ctx.beginPath(); ctx.arc(d.x, d.y, dotR, 0, Math.PI * 2); ctx.fillStyle = d.color; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth = 1.5; ctx.stroke(); const initials = (d.profile.name || '?').slice(0, 2).toUpperCase(); ctx.font = `700 ${Math.max(7, dotR * 0.82)}px Inter, system-ui, sans-serif`; ctx.fillStyle = '#000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(initials, d.x, d.y); }); return { dots, size }; } // --- DISC Wheel --- // Shared inner-circle center: 4 pie quadrants for D, I, S, C function drawWheelCenter(ctx, cx, cy, innerR) { const quarter = Math.PI / 2; // Center each quadrant on its pure type: offset by half a segment (π/16) clockwise const pieOffset = -Math.PI * 3 / 4 - quarter / 2 + Math.PI / 16; const dims = ['D', 'I', 'S', 'C']; const r = innerR - 2; for (let i = 0; i < 4; i++) { const a1 = pieOffset + i * quarter; const a2 = a1 + quarter; // Filled wedge ctx.beginPath(); ctx.moveTo(cx, cy); ctx.arc(cx, cy, r, a1, a2); ctx.closePath(); ctx.fillStyle = DISC_COLORS[dims[i]] + '30'; ctx.fill(); // Divider line ctx.beginPath(); ctx.moveTo(cx, cy); ctx.lineTo(cx + Math.cos(a1) * r, cy + Math.sin(a1) * r); ctx.strokeStyle = 'rgba(255,255,255,0.15)'; ctx.lineWidth = 1; ctx.stroke(); } // Letters centered in each quadrant const letterR = r * 0.5; ctx.font = '700 18px Inter, system-ui, sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; for (let i = 0; i < 4; i++) { const mid = pieOffset + (i + 0.5) * quarter; ctx.fillStyle = DISC_COLORS[dims[i]]; ctx.fillText(dims[i], cx + Math.cos(mid) * letterR, cy + Math.sin(mid) * letterR); } } export async function drawDiscWheel(canvas, activeType, scores, iconImages = {}) { const { ctx, w, h } = initCanvas(canvas, 400); const cx = w / 2, cy = h / 2; const outerR = 160, innerR = 72; const segAngle = (Math.PI * 2) / 16; // Tilt 45° CCW: D at top-left const startOffset = -Math.PI * 3 / 4; // Draw segments for (let i = 0; i < 16; i++) { const typeId = WHEEL_TYPES[i]; const data = PERSONALITY_DATA[typeId]; const a1 = startOffset + i * segAngle; const a2 = a1 + segAngle; const isActive = typeId === activeType; // Segment fill ctx.beginPath(); ctx.arc(cx, cy, outerR, a1, a2); ctx.arc(cx, cy, innerR, a2, a1, true); ctx.closePath(); ctx.fillStyle = data.color + (isActive ? 'cc' : '44'); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1; ctx.stroke(); if (isActive) { ctx.save(); ctx.shadowColor = data.color; ctx.shadowBlur = 16; ctx.beginPath(); ctx.arc(cx, cy, outerR, a1, a2); ctx.arc(cx, cy, innerR, a2, a1, true); ctx.closePath(); ctx.strokeStyle = data.color; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); } // Type label const midAngle = a1 + segAngle / 2; const labelR = (outerR + innerR) / 2 + 6; const lx = cx + Math.cos(midAngle) * labelR; const ly = cy + Math.sin(midAngle) * labelR; ctx.save(); ctx.translate(lx, ly); ctx.rotate(midAngle + Math.PI / 2); ctx.font = `${isActive ? '700' : '600'} ${isActive ? '11' : '9'}px Inter, system-ui, sans-serif`; ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.7)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(typeId, 0, 0); ctx.restore(); // Icon in segment const icon = iconImages[typeId]; if (icon) { const iconR = (outerR + innerR) / 2 - 12; const ix = cx + Math.cos(midAngle) * iconR; const iy = cy + Math.sin(midAngle) * iconR; const iconSize = 14; ctx.save(); ctx.globalAlpha = isActive ? 0.9 : 0.35; ctx.drawImage(icon, ix - iconSize / 2, iy - iconSize / 2, iconSize, iconSize); ctx.globalAlpha = 1; ctx.restore(); } } // Inner circle: dimension arcs + letters drawWheelCenter(ctx, cx, cy, innerR); } export async function drawMultiDiscWheel(canvas, profiles, iconImages = {}) { const profileColors = ['#ffffff','#9966ff','#ff6b9d','#4ecdc4','#ffe66d','#ff8a5c','#a8e6cf','#ff4757']; const { ctx, w, h } = initCanvas(canvas, 480); const cx = w / 2, cy = h / 2; const outerR = 160, innerR = 72; const segAngle = (Math.PI * 2) / 16; const startOffset = -Math.PI * 3 / 4; // Collect active types const activeTypes = new Set(profiles.map(p => p.type)); // Draw segments for (let i = 0; i < 16; i++) { const typeId = WHEEL_TYPES[i]; const data = PERSONALITY_DATA[typeId]; const a1 = startOffset + i * segAngle; const a2 = a1 + segAngle; const isActive = activeTypes.has(typeId); ctx.beginPath(); ctx.arc(cx, cy, outerR, a1, a2); ctx.arc(cx, cy, innerR, a2, a1, true); ctx.closePath(); ctx.fillStyle = data.color + (isActive ? '88' : '33'); ctx.fill(); ctx.strokeStyle = 'rgba(255,255,255,0.2)'; ctx.lineWidth = 1; ctx.stroke(); if (isActive) { ctx.save(); ctx.shadowColor = data.color; ctx.shadowBlur = 12; ctx.beginPath(); ctx.arc(cx, cy, outerR, a1, a2); ctx.arc(cx, cy, innerR, a2, a1, true); ctx.closePath(); ctx.strokeStyle = data.color; ctx.lineWidth = 2; ctx.stroke(); ctx.restore(); } const midAngle = a1 + segAngle / 2; const labelR = (outerR + innerR) / 2 + 6; const lx = cx + Math.cos(midAngle) * labelR; const ly = cy + Math.sin(midAngle) * labelR; ctx.save(); ctx.translate(lx, ly); ctx.rotate(midAngle + Math.PI / 2); ctx.font = `${isActive ? '700' : '600'} ${isActive ? '11' : '9'}px Inter, system-ui, sans-serif`; ctx.fillStyle = isActive ? '#ffffff' : 'rgba(255,255,255,0.7)'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(typeId, 0, 0); ctx.restore(); const icon = iconImages[typeId]; if (icon) { const iconR = (outerR + innerR) / 2 - 12; const ix = cx + Math.cos(midAngle) * iconR; const iy = cy + Math.sin(midAngle) * iconR; ctx.save(); ctx.globalAlpha = isActive ? 0.9 : 0.3; ctx.drawImage(icon, ix - 7, iy - 7, 14, 14); ctx.globalAlpha = 1; ctx.restore(); } } // Inner circle: dimension arcs + letters drawWheelCenter(ctx, cx, cy, innerR); // Build map of type -> profiles const typeProfiles = {}; profiles.forEach((p, idx) => { if (!typeProfiles[p.type]) typeProfiles[p.type] = []; typeProfiles[p.type].push({ ...p, dotColor: profileColors[idx % profileColors.length] }); }); // Draw dots with initials outside active segments const dotR = 11; const allDots = []; for (let i = 0; i < 16; i++) { const typeId = WHEEL_TYPES[i]; const entries = typeProfiles[typeId]; if (!entries) continue; const a1 = startOffset + i * segAngle; const midAngle = a1 + segAngle / 2; entries.forEach((entry, ni) => { const spread = entries.length > 1 ? segAngle * 0.6 : 0; const angleOffset = entries.length > 1 ? -spread / 2 + (spread / (entries.length - 1)) * ni : 0; const angle = midAngle + angleOffset; const dist = outerR + dotR + 4; const x = cx + Math.cos(angle) * dist; const y = cy + Math.sin(angle) * dist; ctx.beginPath(); ctx.arc(x, y, dotR, 0, Math.PI * 2); ctx.fillStyle = entry.dotColor; ctx.fill(); ctx.strokeStyle = 'rgba(0,0,0,0.4)'; ctx.lineWidth = 1.5; ctx.stroke(); const initials = (entry.name || '?').slice(0, 2).toUpperCase(); ctx.font = '700 9px Inter, system-ui, sans-serif'; ctx.fillStyle = '#000'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.fillText(initials, x, y); allDots.push({ x, y, r: dotR, color: entry.dotColor, profile: entry }); }); } return { dots: allDots, size: 480 }; } // --- Interactive canvas dots --- let _tooltipEl = null; function getTooltip() { if (!_tooltipEl) { _tooltipEl = document.createElement('div'); _tooltipEl.className = 'canvas-dot-tip'; document.body.appendChild(_tooltipEl); } return _tooltipEl; } export function addCanvasDotInteractivity(canvas, dotData) { const { dots, size } = dotData; const tip = getTooltip(); function mousePos(e) { const r = canvas.getBoundingClientRect(); return { x: (e.clientX - r.left) / r.width * size, y: (e.clientY - r.top) / r.height * size }; } function findHit(mx, my) { for (const d of dots) { const dx = mx - d.x, dy = my - d.y; if (dx * dx + dy * dy <= (d.r + 2) * (d.r + 2)) return d; } return null; } canvas.addEventListener('mousemove', e => { const { x, y } = mousePos(e); const hit = findHit(x, y); if (hit) { canvas.style.cursor = 'pointer'; const pers = getPersonality(hit.profile.type); tip.innerHTML = `${hit.profile.name}
${hit.profile.type} — ${pers.name || ''}`; tip.style.display = 'block'; tip.style.left = (e.pageX + 14) + 'px'; tip.style.top = (e.pageY - 12) + 'px'; } else { canvas.style.cursor = ''; tip.style.display = 'none'; } }); canvas.addEventListener('mouseleave', () => { tip.style.display = 'none'; canvas.style.cursor = ''; }); canvas.addEventListener('click', e => { const { x, y } = mousePos(e); const hit = findHit(x, y); if (hit) { let url = `results.html?r=${encodeResult(hit.profile.scores)}`; if (hit.profile.name) url += '&name=' + encodeURIComponent(hit.profile.name); window.open(url, '_blank'); } }); } // --- Icon Preloading --- export async function preloadIcons(iconGenerators, size = 16) { const images = {}; const promises = Object.entries(iconGenerators).map(([id, fn]) => new Promise(resolve => { const svg = fn(size, '#ffffff'); const img = new Image(); img.onload = () => { images[id] = img; resolve(); }; img.onerror = () => resolve(); img.src = 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg); }) ); await Promise.all(promises); return images; }