Snapshot of main at 9f39a6e1e (2026-11-02)
This commit is contained in:
commit
6ccf1792d7
2580 files changed
+1040202
No files matched your search
@@ -0,0 +1,266 @@
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
window.initAccountBalancesChart = function(timelineData, decimalFormat) {
|
||||
const chartCanvas = document.getElementById('accountBalancesChart');
|
||||
if (!chartCanvas || !timelineData || timelineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (chartCanvas.chart) {
|
||||
chartCanvas.chart.destroy();
|
||||
}
|
||||
|
||||
// Format minutes as either HH:MM or decimal hours
|
||||
function formatMinutes(minutes) {
|
||||
if (decimalFormat) {
|
||||
const isNegative = minutes < 0;
|
||||
const absMinutes = Math.abs(minutes);
|
||||
const decimal = (absMinutes / 60).toFixed(2).replace('.', ',');
|
||||
return (isNegative ? '-' : '') + decimal + ' h.';
|
||||
}
|
||||
const isNegative = minutes < 0;
|
||||
const absMinutes = Math.abs(minutes);
|
||||
const wholeHours = Math.floor(absMinutes / 60);
|
||||
const restMinutes = String(absMinutes % 60).padStart(2, '0');
|
||||
return 'H ' + (isNegative ? '-' : '') + wholeHours + ':' + restMinutes;
|
||||
}
|
||||
|
||||
try {
|
||||
// Prepare datasets for each account
|
||||
const datasets = [];
|
||||
const allAccounts = new Map();
|
||||
|
||||
timelineData.forEach(month => {
|
||||
if (month.accounts && Array.isArray(month.accounts)) {
|
||||
month.accounts.forEach(account => {
|
||||
if (!allAccounts.has(account.account_id)) {
|
||||
allAccounts.set(account.account_id, {
|
||||
name: account.account_name,
|
||||
id: account.account_id
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const colors = ['#3B82F6', '#10B981', '#F59E0B', '#EF4444'];
|
||||
let colorIndex = 0;
|
||||
|
||||
allAccounts.forEach((accountInfo, accountId) => {
|
||||
const accountData = timelineData.map(month => {
|
||||
const accountBalance = month.accounts?.find(acc => acc.account_id === accountId);
|
||||
const minutes = accountBalance ? parseInt(accountBalance.balance) : 0;
|
||||
// Convert minutes to decimal hours for chart display
|
||||
return minutes / 60;
|
||||
});
|
||||
|
||||
datasets.push({
|
||||
label: accountInfo.name,
|
||||
data: accountData,
|
||||
borderColor: colors[colorIndex % colors.length],
|
||||
backgroundColor: colors[colorIndex % colors.length] + '33', // 0.2 transparency (33 in hex = 20% opacity)
|
||||
borderWidth: 3,
|
||||
tension: 0.4, // More curved line
|
||||
fill: true, // Fill below the line
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
pointBackgroundColor: colors[colorIndex % colors.length],
|
||||
pointBorderColor: '#ffffff',
|
||||
pointBorderWidth: 2,
|
||||
});
|
||||
|
||||
colorIndex++;
|
||||
});
|
||||
|
||||
if (datasets.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const chartLabels = timelineData.map(item => item.label);
|
||||
const chartType = chartLabels.length === 1 ? 'bar' : 'line';
|
||||
|
||||
// Get translated labels from the first data point
|
||||
const translations = timelineData[0]?.translations || {
|
||||
period: 'Period',
|
||||
balance: 'Balance'
|
||||
};
|
||||
|
||||
const config = {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: chartLabels,
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
mode: 'index',
|
||||
intersect: false,
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
color: '#1F2937'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
callbacks: {
|
||||
label: function(context) {
|
||||
const minutes = Math.round(context.parsed.y * 60);
|
||||
return context.dataset.label + ': ' + formatMinutes(minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.period,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280',
|
||||
// Every month keeps its label, angled so they fit.
|
||||
autoSkip: false,
|
||||
maxRotation: 60,
|
||||
minRotation: 60,
|
||||
padding: 8,
|
||||
font: { size: 10 }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.balance,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280',
|
||||
callback: function(value) {
|
||||
const minutes = Math.round(value * 60);
|
||||
return formatMinutes(minutes);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
chartCanvas.chart = new Chart(ctx, config);
|
||||
|
||||
// Store chart instance globally for PDF export
|
||||
window.accountBalancesChart = chartCanvas.chart;
|
||||
|
||||
} catch (error) {
|
||||
}
|
||||
};
|
||||
|
||||
// EXACT COPY of return ratio chart initialization pattern
|
||||
function initializeChart() {
|
||||
// Find chart container
|
||||
const chartContainer = document.querySelector('[data-balance-chart-data]');
|
||||
if (!chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get chart data
|
||||
const timelineDataAttr = chartContainer.getAttribute('data-balance-chart-data');
|
||||
|
||||
if (!timelineDataAttr) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timelineData = JSON.parse(timelineDataAttr);
|
||||
const decimalFormat = chartContainer.getAttribute('data-decimal-format') === '1';
|
||||
|
||||
// Initialize the chart
|
||||
if (typeof window.initAccountBalancesChart === 'function') {
|
||||
window.initAccountBalancesChart(timelineData, decimalFormat);
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize chart on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeChart, 100);
|
||||
});
|
||||
|
||||
// Listen for Livewire updates - debounced to prevent rapid re-initialization
|
||||
document.addEventListener('livewire:init', () => {
|
||||
let morphTimer = null;
|
||||
Livewire.hook('morph.updated', () => {
|
||||
clearTimeout(morphTimer);
|
||||
morphTimer = setTimeout(() => {
|
||||
initializeChart();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// Function to export Account Balances chart as base64 image for PDF
|
||||
window.exportAccountBalancesChartForPdf = async function() {
|
||||
|
||||
if (!window.accountBalancesChart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalChart = window.accountBalancesChart;
|
||||
|
||||
// Render into an offscreen canvas to avoid touching the live DOM canvas
|
||||
// (which can be detached by Livewire morphs mid-export)
|
||||
const pdfWidth = 900;
|
||||
const pdfHeight = 400;
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = pdfWidth;
|
||||
offscreen.height = pdfHeight;
|
||||
const ctx = offscreen.getContext('2d');
|
||||
|
||||
// Clone config from existing chart
|
||||
const offscreenChart = new Chart(ctx, {
|
||||
type: originalChart.config.type,
|
||||
data: JSON.parse(JSON.stringify(originalChart.config.data)),
|
||||
options: {
|
||||
...JSON.parse(JSON.stringify(originalChart.config.options || {})),
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for render
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const chartImage = offscreen.toDataURL('image/png', 1.0);
|
||||
|
||||
offscreenChart.destroy();
|
||||
|
||||
return chartImage;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,159 @@
|
||||
// SNAPSHOT
|
||||
|
||||
// Profile switch banner
|
||||
// The meta tag approach fails because Livewire navigate does a full document replace,
|
||||
// destroying all observers/listeners. Instead we use localStorage as the handoff:
|
||||
// 1. Server renders meta[name="profile-switch-data"] with JSON
|
||||
// 2. A tiny IIFE in <head> (non-deferred, runs immediately) reads the meta,
|
||||
// writes to localStorage, and shows the banner.
|
||||
// This file just exports the show function for the head script to call.
|
||||
window.__showProfileSwitchBanner = function(data) {
|
||||
const banner = document.getElementById('profile-switch-banner');
|
||||
if (!banner) return;
|
||||
|
||||
const img = document.getElementById('profile-switch-banner-photo');
|
||||
if (img) img.style.display = 'none';
|
||||
const roleEl = document.getElementById('profile-switch-banner-role');
|
||||
if (roleEl) { roleEl.style.display = 'none'; roleEl.textContent = ''; }
|
||||
|
||||
if (data.photo && img) { img.src = data.photo; img.alt = data.name || ''; img.style.display = 'block'; }
|
||||
const nameEl = document.getElementById('profile-switch-banner-name');
|
||||
if (nameEl) nameEl.textContent = data.name || '';
|
||||
if (roleEl && data.roleLabel) { roleEl.textContent = data.roleLabel; roleEl.style.display = 'block'; }
|
||||
|
||||
banner.style.opacity = '1';
|
||||
banner.style.transition = '';
|
||||
banner.style.display = 'flex';
|
||||
banner.addEventListener('click', () => { banner.style.display = 'none'; }, { once: true });
|
||||
setTimeout(() => {
|
||||
banner.style.transition = 'opacity 0.4s';
|
||||
banner.style.opacity = '0';
|
||||
setTimeout(() => { banner.style.display = 'none'; banner.style.opacity = ''; banner.style.transition = ''; }, 400);
|
||||
}, 3000);
|
||||
};
|
||||
|
||||
import './bootstrap';
|
||||
import { createPopper } from "@popperjs/core";
|
||||
import focus from "@alpinejs/focus";
|
||||
import tagifyMin from "@yaireo/tagify";
|
||||
import Echo from 'laravel-echo';
|
||||
import Pusher from 'pusher-js';
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
import './presence-tracker.js';
|
||||
import './return-ratio-chart.js';
|
||||
import './account-balances-chart.js';
|
||||
import './stats-charts.js';
|
||||
// Cytoscape wordt lazy geladen binnen deze module (zie stats-network.js).
|
||||
import './stats-network.js';
|
||||
import './report-balance-distribution-chart.js';
|
||||
|
||||
|
||||
Alpine.plugin(focus);
|
||||
|
||||
// Register Chart.js globally
|
||||
Chart.register(...registerables);
|
||||
window.Chart = Chart;
|
||||
|
||||
window.createPopper = createPopper;
|
||||
window.Tagify = tagifyMin; // Needs to be loaded after Alpine
|
||||
|
||||
// Lazy-load JSZip (used by backup restore chunked upload)
|
||||
window.loadJSZip = () => import('jszip').then(m => m.default);
|
||||
|
||||
// Alpine component for the call tag picker (livewire:calls.call-skill-input)
|
||||
// Registered via Alpine.data() so Alpine can resolve it when Livewire morphs in new DOM nodes.
|
||||
document.addEventListener('alpine:init', () => {
|
||||
Alpine.data('callTagPicker', () => ({
|
||||
callTagify: null,
|
||||
init() {
|
||||
const input = this.$refs.callTagsInput;
|
||||
const suggestions = JSON.parse(input.dataset.suggestions || '[]');
|
||||
|
||||
this.callTagify = new Tagify(input, {
|
||||
maxTags: 1,
|
||||
whitelist: suggestions,
|
||||
enforceWhiteList: false,
|
||||
backspace: false,
|
||||
editTags: false,
|
||||
addTagOn: ['enter', 'tab', 'blur'],
|
||||
addTagOnBlur: true,
|
||||
dropdown: {
|
||||
maxItems: 10,
|
||||
enabled: 2,
|
||||
closeOnSelect: true,
|
||||
highlightFirst: true,
|
||||
},
|
||||
});
|
||||
|
||||
// Pre-populate with initial value from server (e.g. edit modal)
|
||||
const initialTags = this.$wire.get('tagsArray');
|
||||
if (initialTags && initialTags !== '[]') {
|
||||
this.callTagify.loadOriginalValues(initialTags);
|
||||
}
|
||||
|
||||
this.callTagify.on('change', () => {
|
||||
// Read from the actual input element — e.target can be a #text node in Tagify
|
||||
const rawValue = this.$refs.callTagsInput.value;
|
||||
const tags = JSON.parse(rawValue || '[]');
|
||||
if (tags.length === 0) {
|
||||
this.$wire.set('tagsArray', '[]', false);
|
||||
this.$wire.call('notifyTagCleared');
|
||||
return;
|
||||
}
|
||||
const tag = tags[0];
|
||||
if (tag.tag_id) {
|
||||
// Known tag — sync and notify parent
|
||||
this.$wire.set('tagsArray', rawValue, false);
|
||||
this.$wire.call('notifyTagSelected', tag.tag_id);
|
||||
} else {
|
||||
// Unknown tag — open creation modal (no set() to avoid re-render race)
|
||||
this.$wire.call('openNewTagModal', tag.value);
|
||||
}
|
||||
});
|
||||
|
||||
// Server asks us to remove the pending unconfirmed tag (cancel modal)
|
||||
window.addEventListener('removeLastCallTag', () => {
|
||||
if (this.callTagify && this.callTagify.value.length > 0) {
|
||||
this.callTagify.removeTag(
|
||||
this.callTagify.value[this.callTagify.value.length - 1].value
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Server pushes a new tagsArray after createTag — reload Tagify with colored badge
|
||||
Livewire.on('callTagifyReload', (data) => {
|
||||
this.callTagify.loadOriginalValues(data.tagsArray);
|
||||
});
|
||||
}
|
||||
}));
|
||||
});
|
||||
|
||||
window.Pusher = Pusher;
|
||||
|
||||
// Get the current locale from the URL (e.g., /en/, /nl/, etc.)
|
||||
const getLocalePrefix = () => {
|
||||
const pathParts = window.location.pathname.split('/').filter(Boolean);
|
||||
const locales = ['en', 'nl', 'de', 'es', 'fr'];
|
||||
if (pathParts.length > 0 && locales.includes(pathParts[0])) {
|
||||
return `/${pathParts[0]}`;
|
||||
}
|
||||
return '/en'; // Default to English if no locale found
|
||||
};
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
wsPath: import.meta.env.VITE_REVERB_PATH ?? "/",
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https",
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: `${getLocalePrefix()}/broadcasting/auth`,
|
||||
auth: {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content')
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Vendored
+35
@@ -0,0 +1,35 @@
|
||||
import _ from 'lodash';
|
||||
window._ = _;
|
||||
|
||||
/**
|
||||
* We'll load the axios HTTP library which allows us to easily issue requests
|
||||
* to our Laravel back-end. This library automatically handles sending the
|
||||
* CSRF token as a header based on the value of the "XSRF" token cookie.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
window.axios = axios;
|
||||
|
||||
window.axios.defaults.headers.common['X-Requested-With'] = 'XMLHttpRequest';
|
||||
|
||||
|
||||
// Reload page when active profile timeouts. Important for Banks ans Admin profiles.
|
||||
if (window.axios) {
|
||||
window.axios.interceptors.response.use(
|
||||
response => response,
|
||||
error => {
|
||||
// Check specifically for the 419 status code
|
||||
if (error.response && error.response.status === 419) {
|
||||
if (error.response.data && error.response.data.action === 'redirect' && error.response.data.redirect_url) {
|
||||
window.location.href = error.response.data.redirect_url; // Use the URL from payload
|
||||
} else {
|
||||
window.location.href = '/login'; // Redirect to generic login as fallback
|
||||
}
|
||||
}
|
||||
// Important: Reject the promise so other error handlers can process it
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
|
||||
/**
|
||||
* Echo exposes an expressive API for subscribing to channels and listening
|
||||
* for events that are broadcast by Laravel. Echo and event broadcasting
|
||||
* allows your team to easily build robust real-time web applications.
|
||||
*/
|
||||
|
||||
|
||||
import Echo from 'laravel-echo';
|
||||
|
||||
window.Pusher = require('pusher-js');
|
||||
|
||||
// Get CSRF token from meta tag
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content');
|
||||
|
||||
// Get current locale from URL path (e.g., /en/, /nl/, etc.)
|
||||
const locale = window.location.pathname.split('/')[1] || 'en';
|
||||
|
||||
window.Echo = new Echo({
|
||||
broadcaster: 'reverb',
|
||||
key: import.meta.env.VITE_REVERB_APP_KEY,
|
||||
wsHost: import.meta.env.VITE_REVERB_HOST,
|
||||
wsPort: import.meta.env.VITE_REVERB_PORT ?? 80,
|
||||
wssPort: import.meta.env.VITE_REVERB_PORT ?? 443,
|
||||
wsPath: import.meta.env.VITE_REVERB_PATH ?? "/",
|
||||
forceTLS: (import.meta.env.VITE_REVERB_SCHEME ?? "https") === "https",
|
||||
enabledTransports: ['ws', 'wss'],
|
||||
authEndpoint: `/${locale}/broadcasting/auth`,
|
||||
auth: {
|
||||
headers: {
|
||||
'X-CSRF-TOKEN': csrfToken,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
Vendored
+15
@@ -0,0 +1,15 @@
|
||||
/*! emoji-toolkit 22-02-2021 */
|
||||
!function(ns){ns.emojiList={":england:":{uc_base:"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f",uc_full:"1f3f4-e0067-e0062-e0065-e006e-e0067-e007f",shortnames:[],category:"flags"},":scotland:":{uc_base:"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f",uc_full:"1f3f4-e0067-e0062-e0073-e0063-e0074-e007f",shortnames:[],category:"flags"},":wales:":{uc_base:"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f",uc_full:"1f3f4-e0067-e0062-e0077-e006c-e0073-e007f",shortnames:[],category:"flags"},":kiss_man_man_tone1:":{uc_base:"1f468-1f3fb-2764-1f48b-1f468-1f3fb",uc_full:"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb",shortnames:[":kiss_man_man_light_skin_tone:"],category:"people"},":kiss_man_man_tone1_tone2:":{uc_base:"1f468-1f3fb-2764-1f48b-1f468-1f3fc",uc_full:"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc",shortnames:[":kiss_man_man_light_skin_tone_medium_light_skin_tone:"],category:"people"},":kiss_man_man_tone1_tone3:":{uc_base:"1f468-1f3fb-2764-1f48b-1f468-1f3fd",uc_full:"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd",shortnames:[":kiss_man_man_light_skin_tone_medium_skin_tone:"],category:"people"},":kiss_man_man_tone1_tone4:":{uc_base:"1f468-1f3fb-2764-1f48b-1f468-1f3fe",uc_full:"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe",shortnames:[":kiss_man_man_light_skin_tone_medium_dark_skin_tone:"],category:"people"},":kiss_man_man_tone1_tone5:":{uc_base:"1f468-1f3fb-2764-1f48b-1f468-1f3ff",uc_full:"1f468-1f3fb-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff",shortnames:[":kiss_man_man_light_skin_tone_dark_skin_tone:"],category:"people"},":kiss_man_man_tone2:":{uc_base:"1f468-1f3fc-2764-1f48b-1f468-1f3fc",uc_full:"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc",shortnames:[":kiss_man_man_medium_light_skin_tone:"],category:"people"},":kiss_man_man_tone2_tone1:":{uc_base:"1f468-1f3fc-2764-1f48b-1f468-1f3fb",uc_full:"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb",shortnames:[":kiss_man_man_medium_light_skin_tone_light_skin_tone:"],category:"people"},":kiss_man_man_tone2_tone3:":{uc_base:"1f468-1f3fc-2764-1f48b-1f468-1f3fd",uc_full:"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd",shortnames:[":kiss_man_man_medium_light_skin_tone_medium_skin_tone:"],category:"people"},":kiss_man_man_tone2_tone4:":{uc_base:"1f468-1f3fc-2764-1f48b-1f468-1f3fe",uc_full:"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe",shortnames:[":kiss_man_man_medium_light_skin_tone_medium_dark_skin_tone:"],category:"people"},":kiss_man_man_tone2_tone5:":{uc_base:"1f468-1f3fc-2764-1f48b-1f468-1f3ff",uc_full:"1f468-1f3fc-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff",shortnames:[":kiss_man_man_medium_light_skin_tone_dark_skin_tone:"],category:"people"},":kiss_man_man_tone3:":{uc_base:"1f468-1f3fd-2764-1f48b-1f468-1f3fd",uc_full:"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd",shortnames:[":kiss_man_man_medium_skin_tone:"],category:"people"},":kiss_man_man_tone3_tone1:":{uc_base:"1f468-1f3fd-2764-1f48b-1f468-1f3fb",uc_full:"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb",shortnames:[":kiss_man_man_medium_skin_tone_light_skin_tone:"],category:"people"},":kiss_man_man_tone3_tone2:":{uc_base:"1f468-1f3fd-2764-1f48b-1f468-1f3fc",uc_full:"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc",shortnames:[":kiss_man_man_medium_skin_tone_medium_light_skin_tone:"],category:"people"},":kiss_man_man_tone3_tone4:":{uc_base:"1f468-1f3fd-2764-1f48b-1f468-1f3fe",uc_full:"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe",shortnames:[":kiss_man_man_medium_skin_tone_medium_dark_skin_tone:"],category:"people"},":kiss_man_man_tone3_tone5:":{uc_base:"1f468-1f3fd-2764-1f48b-1f468-1f3ff",uc_full:"1f468-1f3fd-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff",shortnames:[":kiss_man_man_medium_skin_tone_dark_skin_tone:"],category:"people"},":kiss_man_man_tone4:":{uc_base:"1f468-1f3fe-2764-1f48b-1f468-1f3fe",uc_full:"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fe",shortnames:[":kiss_man_man_medium_dark_skin_tone:"],category:"people"},":kiss_man_man_tone4_tone1:":{uc_base:"1f468-1f3fe-2764-1f48b-1f468-1f3fb",uc_full:"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fb",shortnames:[":kiss_man_man_medium_dark_skin_tone_light_skin_tone:"],category:"people"},":kiss_man_man_tone4_tone2:":{uc_base:"1f468-1f3fe-2764-1f48b-1f468-1f3fc",uc_full:"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fc",shortnames:[":kiss_man_man_medium_dark_skin_tone_medium_light_skin_tone:"],category:"people"},":kiss_man_man_tone4_tone3:":{uc_base:"1f468-1f3fe-2764-1f48b-1f468-1f3fd",uc_full:"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3fd",shortnames:[":kiss_man_man_medium_dark_skin_tone_medium_skin_tone:"],category:"people"},":kiss_man_man_tone4_tone5:":{uc_base:"1f468-1f3fe-2764-1f48b-1f468-1f3ff",uc_full:"1f468-1f3fe-200d-2764-fe0f-200d-1f48b-200d-1f468-1f3ff",shortnames:[":kiss_man_man_medium_dark_skin_tone_dark_skin_tone:"],category:"people"},":kiss_man_man_tone5:":{uc_base:"1f468-1f3ff-2764-1f48b-1f468-1f3ff",uc_full:"1f468-1Line truncated
|
||||
shortnames:[":woman_and_man_holding_hands_light_skin_tone_medium_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone1_tone3:":{uc_base:"1f469-1f3fb-1f91d-1f468-1f3fd",uc_full:"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fd",shortnames:[":woman_and_man_holding_hands_light_skin_tone_medium_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone1_tone4:":{uc_base:"1f469-1f3fb-1f91d-1f468-1f3fe",uc_full:"1f469-1f3fb-200d-1f91d-200d-1f468-1f3fe",shortnames:[":woman_and_man_holding_hands_light_skin_tone_medium_dark_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone1_tone5:":{uc_base:"1f469-1f3fb-1f91d-1f468-1f3ff",uc_full:"1f469-1f3fb-200d-1f91d-200d-1f468-1f3ff",shortnames:[":woman_and_man_holding_hands_light_skin_tone_dark_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone2_tone1:":{uc_base:"1f469-1f3fc-1f91d-1f468-1f3fb",uc_full:"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fb",shortnames:[":woman_and_man_holding_hands_medium_light_skin_tone_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone2_tone3:":{uc_base:"1f469-1f3fc-1f91d-1f468-1f3fd",uc_full:"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fd",shortnames:[":woman_and_man_holding_hands_medium_light_skin_tone_medium_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone2_tone4:":{uc_base:"1f469-1f3fc-1f91d-1f468-1f3fe",uc_full:"1f469-1f3fc-200d-1f91d-200d-1f468-1f3fe",shortnames:[":woman_and_man_holding_hands_medium_light_skin_tone_medium_dark_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone2_tone5:":{uc_base:"1f469-1f3fc-1f91d-1f468-1f3ff",uc_full:"1f469-1f3fc-200d-1f91d-200d-1f468-1f3ff",shortnames:[":woman_and_man_holding_hands_medium_light_skin_tone_dark_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone3_tone1:":{uc_base:"1f469-1f3fd-1f91d-1f468-1f3fb",uc_full:"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fb",shortnames:[":woman_and_man_holding_hands_medium_skin_tone_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone3_tone2:":{uc_base:"1f469-1f3fd-1f91d-1f468-1f3fc",uc_full:"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fc",shortnames:[":woman_and_man_holding_hands_medium_skin_tone_medium_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone3_tone4:":{uc_base:"1f469-1f3fd-1f91d-1f468-1f3fe",uc_full:"1f469-1f3fd-200d-1f91d-200d-1f468-1f3fe",shortnames:[":woman_and_man_holding_hands_medium_skin_tone_medium_dark_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone3_tone5:":{uc_base:"1f469-1f3fd-1f91d-1f468-1f3ff",uc_full:"1f469-1f3fd-200d-1f91d-200d-1f468-1f3ff",shortnames:[":woman_and_man_holding_hands_medium_skin_tone_dark_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone4_tone1:":{uc_base:"1f469-1f3fe-1f91d-1f468-1f3fb",uc_full:"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fb",shortnames:[":woman_and_man_holding_hands_medium_dark_skin_tone_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone4_tone2:":{uc_base:"1f469-1f3fe-1f91d-1f468-1f3fc",uc_full:"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fc",shortnames:[":woman_and_man_holding_hands_medium_dark_skin_tone_medium_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone4_tone3:":{uc_base:"1f469-1f3fe-1f91d-1f468-1f3fd",uc_full:"1f469-1f3fe-200d-1f91d-200d-1f468-1f3fd",shortnames:[":woman_and_man_holding_hands_medium_dark_skin_tone_medium_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone4_tone5:":{uc_base:"1f469-1f3fe-1f91d-1f468-1f3ff",uc_full:"1f469-1f3fe-200d-1f91d-200d-1f468-1f3ff",shortnames:[":woman_and_man_holding_hands_medium_dark_skin_tone_dark_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone5_tone1:":{uc_base:"1f469-1f3ff-1f91d-1f468-1f3fb",uc_full:"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fb",shortnames:[":woman_and_man_holding_hands_dark_skin_tone_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone5_tone2:":{uc_base:"1f469-1f3ff-1f91d-1f468-1f3fc",uc_full:"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fc",shortnames:[":woman_and_man_holding_hands_dark_skin_tone_medium_light_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone5_tone3:":{uc_base:"1f469-1f3ff-1f91d-1f468-1f3fd",uc_full:"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fd",shortnames:[":woman_and_man_holding_hands_dark_skin_tone_medium_skin_tone:"],category:"people"},":woman_and_man_holding_hands_tone5_tone4:":{uc_base:"1f469-1f3ff-1f91d-1f468-1f3fe",uc_full:"1f469-1f3ff-200d-1f91d-200d-1f468-1f3fe",shortnames:[":woman_and_man_holding_hands_dark_skin_tone_medium_dark_skin_tone:"],category:"people"},":women_holding_hands_tone1_tone2:":{uc_base:"1f469-1f3fb-1f91d-1f469-1f3fc",uc_full:"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fc",shortnames:[":women_holding_hands_light_skin_tone_medium_light_skin_tone:"],category:"people"},":women_holding_hands_tone1_tone3:":{uc_base:"1f469-1f3fb-1f91d-1f469-1f3fd",uc_full:"1f469-1f3fb-200d-1f91d-200d-1f469-1f3fd",shortnames:[":women_holding_hands_light_skin_tone_medium_skinLine truncated
|
||||
shortnames:[],category:"people"},":family_mwgb:":{uc_base:"1f468-1f469-1f467-1f466",uc_full:"1f468-200d-1f469-200d-1f467-200d-1f466",shortnames:[],category:"people"},":family_mwgg:":{uc_base:"1f468-1f469-1f467-1f467",uc_full:"1f468-200d-1f469-200d-1f467-200d-1f467",shortnames:[],category:"people"},":family_wwbb:":{uc_base:"1f469-1f469-1f466-1f466",uc_full:"1f469-200d-1f469-200d-1f466-200d-1f466",shortnames:[],category:"people"},":family_wwgb:":{uc_base:"1f469-1f469-1f467-1f466",uc_full:"1f469-200d-1f469-200d-1f467-200d-1f466",shortnames:[],category:"people"},":family_wwgg:":{uc_base:"1f469-1f469-1f467-1f467",uc_full:"1f469-200d-1f469-200d-1f467-200d-1f467",shortnames:[],category:"people"},":kiss_mm:":{uc_base:"1f468-2764-1f48b-1f468",uc_full:"1f468-200d-2764-fe0f-200d-1f48b-200d-1f468",shortnames:[":couplekiss_mm:"],category:"people"},":kiss_woman_man:":{uc_base:"1f469-2764-1f48b-1f468",uc_full:"1f469-200d-2764-fe0f-200d-1f48b-200d-1f468",shortnames:[],category:"people"},":kiss_ww:":{uc_base:"1f469-2764-1f48b-1f469",uc_full:"1f469-200d-2764-fe0f-200d-1f48b-200d-1f469",shortnames:[":couplekiss_ww:"],category:"people"},":artist_tone1:":{uc_base:"1f9d1-1f3fb-1f3a8",uc_full:"1f9d1-1f3fb-200d-1f3a8",shortnames:[":artist_light_skin_tone:"],category:"people"},":artist_tone2:":{uc_base:"1f9d1-1f3fc-1f3a8",uc_full:"1f9d1-1f3fc-200d-1f3a8",shortnames:[":artist_medium_light_skin_tone:"],category:"people"},":artist_tone3:":{uc_base:"1f9d1-1f3fd-1f3a8",uc_full:"1f9d1-1f3fd-200d-1f3a8",shortnames:[":artist_medium_skin_tone:"],category:"people"},":artist_tone4:":{uc_base:"1f9d1-1f3fe-1f3a8",uc_full:"1f9d1-1f3fe-200d-1f3a8",shortnames:[":artist_medium_dark_skin_tone:"],category:"people"},":artist_tone5:":{uc_base:"1f9d1-1f3ff-1f3a8",uc_full:"1f9d1-1f3ff-200d-1f3a8",shortnames:[":artist_dark_skin_tone:"],category:"people"},":astronaut_tone1:":{uc_base:"1f9d1-1f3fb-1f680",uc_full:"1f9d1-1f3fb-200d-1f680",shortnames:[":astronaut_light_skin_tone:"],category:"people"},":astronaut_tone2:":{uc_base:"1f9d1-1f3fc-1f680",uc_full:"1f9d1-1f3fc-200d-1f680",shortnames:[":astronaut_medium_light_skin_tone:"],category:"people"},":astronaut_tone3:":{uc_base:"1f9d1-1f3fd-1f680",uc_full:"1f9d1-1f3fd-200d-1f680",shortnames:[":astronaut_medium_skin_tone:"],category:"people"},":astronaut_tone4:":{uc_base:"1f9d1-1f3fe-1f680",uc_full:"1f9d1-1f3fe-200d-1f680",shortnames:[":astronaut_medium_dark_skin_tone:"],category:"people"},":astronaut_tone5:":{uc_base:"1f9d1-1f3ff-1f680",uc_full:"1f9d1-1f3ff-200d-1f680",shortnames:[":astronaut_dark_skin_tone:"],category:"people"},":cook_tone1:":{uc_base:"1f9d1-1f3fb-1f373",uc_full:"1f9d1-1f3fb-200d-1f373",shortnames:[":cook_light_skin_tone:"],category:"people"},":cook_tone2:":{uc_base:"1f9d1-1f3fc-1f373",uc_full:"1f9d1-1f3fc-200d-1f373",shortnames:[":cook_medium_light_skin_tone:"],category:"people"},":cook_tone3:":{uc_base:"1f9d1-1f3fd-1f373",uc_full:"1f9d1-1f3fd-200d-1f373",shortnames:[":cook_medium_skin_tone:"],category:"people"},":cook_tone4:":{uc_base:"1f9d1-1f3fe-1f373",uc_full:"1f9d1-1f3fe-200d-1f373",shortnames:[":cook_medium_dark_skin_tone:"],category:"people"},":cook_tone5:":{uc_base:"1f9d1-1f3ff-1f373",uc_full:"1f9d1-1f3ff-200d-1f373",shortnames:[":cook_dark_skin_tone:"],category:"people"},":factory_worker_tone1:":{uc_base:"1f9d1-1f3fb-1f3ed",uc_full:"1f9d1-1f3fb-200d-1f3ed",shortnames:[":factory_worker_light_skin_tone:"],category:"people"},":factory_worker_tone2:":{uc_base:"1f9d1-1f3fc-1f3ed",uc_full:"1f9d1-1f3fc-200d-1f3ed",shortnames:[":factory_worker_medium_light_skin_tone:"],category:"people"},":factory_worker_tone3:":{uc_base:"1f9d1-1f3fd-1f3ed",uc_full:"1f9d1-1f3fd-200d-1f3ed",shortnames:[":factory_worker_medium_skin_tone:"],category:"people"},":factory_worker_tone4:":{uc_base:"1f9d1-1f3fe-1f3ed",uc_full:"1f9d1-1f3fe-200d-1f3ed",shortnames:[":factory_worker_medium_dark_skin_tone:"],category:"people"},":factory_worker_tone5:":{uc_base:"1f9d1-1f3ff-1f3ed",uc_full:"1f9d1-1f3ff-200d-1f3ed",shortnames:[":factory_worker_dark_skin_tone:"],category:"people"},":family_man_boy_boy:":{uc_base:"1f468-1f466-1f466",uc_full:"1f468-200d-1f466-200d-1f466",shortnames:[],category:"people"},":family_man_girl_boy:":{uc_base:"1f468-1f467-1f466",uc_full:"1f468-200d-1f467-200d-1f466",shortnames:[],category:"people"},":family_man_girl_girl:":{uc_base:"1f468-1f467-1f467",uc_full:"1f468-200d-1f467-200d-1f467",shortnames:[],category:"people"},":family_man_woman_boy:":{uc_base:"1f468-1f469-1f466",uc_full:"1f468-200d-1f469-200d-1f466",shortnames:[],category:"people"},":family_mmb:":{uc_base:"1f468-1f468-1f466",uc_full:"1f468-200d-1f468-200d-1f466",shortnames:[],category:"people"},":family_mmg:":{uc_base:"1f468-1f468-1f467",uc_full:"1f468-200d-1f468-200d-1f467",shortnames:[],category:"people"},":family_mwg:":{uc_base:"1f468-1f469-1f467",uc_full:"1f468-200d-1f469-200d-1f467",shortnames:[],category:"people"},":family_woman_boy_boy:":{uc_base:"1f469-1f466-1f466",uc_full:"1f469-200d-1f466-200d-1f466",shortnames:[],category:"peopLine truncated
|
||||
category:"people"},":person_with_probing_cane_tone3:":{uc_base:"1f9d1-1f3fd-1f9af",uc_full:"1f9d1-1f3fd-200d-1f9af",shortnames:[":person_with_probing_cane_medium_skin_tone:"],category:"people"},":person_with_probing_cane_tone4:":{uc_base:"1f9d1-1f3fe-1f9af",uc_full:"1f9d1-1f3fe-200d-1f9af",shortnames:[":person_with_probing_cane_medium_dark_skin_tone:"],category:"people"},":person_with_probing_cane_tone5:":{uc_base:"1f9d1-1f3ff-1f9af",uc_full:"1f9d1-1f3ff-200d-1f9af",shortnames:[":person_with_probing_cane_dark_skin_tone:"],category:"people"},":scientist_tone1:":{uc_base:"1f9d1-1f3fb-1f52c",uc_full:"1f9d1-1f3fb-200d-1f52c",shortnames:[":scientist_light_skin_tone:"],category:"people"},":scientist_tone2:":{uc_base:"1f9d1-1f3fc-1f52c",uc_full:"1f9d1-1f3fc-200d-1f52c",shortnames:[":scientist_medium_light_skin_tone:"],category:"people"},":scientist_tone3:":{uc_base:"1f9d1-1f3fd-1f52c",uc_full:"1f9d1-1f3fd-200d-1f52c",shortnames:[":scientist_medium_skin_tone:"],category:"people"},":scientist_tone4:":{uc_base:"1f9d1-1f3fe-1f52c",uc_full:"1f9d1-1f3fe-200d-1f52c",shortnames:[":scientist_medium_dark_skin_tone:"],category:"people"},":scientist_tone5:":{uc_base:"1f9d1-1f3ff-1f52c",uc_full:"1f9d1-1f3ff-200d-1f52c",shortnames:[":scientist_dark_skin_tone:"],category:"people"},":singer_tone1:":{uc_base:"1f9d1-1f3fb-1f3a4",uc_full:"1f9d1-1f3fb-200d-1f3a4",shortnames:[":singer_light_skin_tone:"],category:"people"},":singer_tone2:":{uc_base:"1f9d1-1f3fc-1f3a4",uc_full:"1f9d1-1f3fc-200d-1f3a4",shortnames:[":singer_medium_light_skin_tone:"],category:"people"},":singer_tone3:":{uc_base:"1f9d1-1f3fd-1f3a4",uc_full:"1f9d1-1f3fd-200d-1f3a4",shortnames:[":singer_medium_skin_tone:"],category:"people"},":singer_tone4:":{uc_base:"1f9d1-1f3fe-1f3a4",uc_full:"1f9d1-1f3fe-200d-1f3a4",shortnames:[":singer_medium_dark_skin_tone:"],category:"people"},":singer_tone5:":{uc_base:"1f9d1-1f3ff-1f3a4",uc_full:"1f9d1-1f3ff-200d-1f3a4",shortnames:[":singer_dark_skin_tone:"],category:"people"},":student_tone1:":{uc_base:"1f9d1-1f3fb-1f393",uc_full:"1f9d1-1f3fb-200d-1f393",shortnames:[":student_light_skin_tone:"],category:"people"},":student_tone2:":{uc_base:"1f9d1-1f3fc-1f393",uc_full:"1f9d1-1f3fc-200d-1f393",shortnames:[":student_medium_light_skin_tone:"],category:"people"},":student_tone3:":{uc_base:"1f9d1-1f3fd-1f393",uc_full:"1f9d1-1f3fd-200d-1f393",shortnames:[":student_medium_skin_tone:"],category:"people"},":student_tone4:":{uc_base:"1f9d1-1f3fe-1f393",uc_full:"1f9d1-1f3fe-200d-1f393",shortnames:[":student_medium_dark_skin_tone:"],category:"people"},":student_tone5:":{uc_base:"1f9d1-1f3ff-1f393",uc_full:"1f9d1-1f3ff-200d-1f393",shortnames:[":student_dark_skin_tone:"],category:"people"},":teacher_tone1:":{uc_base:"1f9d1-1f3fb-1f3eb",uc_full:"1f9d1-1f3fb-200d-1f3eb",shortnames:[":teacher_light_skin_tone:"],category:"people"},":teacher_tone2:":{uc_base:"1f9d1-1f3fc-1f3eb",uc_full:"1f9d1-1f3fc-200d-1f3eb",shortnames:[":teacher_medium_light_skin_tone:"],category:"people"},":teacher_tone3:":{uc_base:"1f9d1-1f3fd-1f3eb",uc_full:"1f9d1-1f3fd-200d-1f3eb",shortnames:[":teacher_medium_skin_tone:"],category:"people"},":teacher_tone4:":{uc_base:"1f9d1-1f3fe-1f3eb",uc_full:"1f9d1-1f3fe-200d-1f3eb",shortnames:[":teacher_medium_dark_skin_tone:"],category:"people"},":teacher_tone5:":{uc_base:"1f9d1-1f3ff-1f3eb",uc_full:"1f9d1-1f3ff-200d-1f3eb",shortnames:[":teacher_dark_skin_tone:"],category:"people"},":technologist_tone1:":{uc_base:"1f9d1-1f3fb-1f4bb",uc_full:"1f9d1-1f3fb-200d-1f4bb",shortnames:[":technologist_light_skin_tone:"],category:"people"},":technologist_tone2:":{uc_base:"1f9d1-1f3fc-1f4bb",uc_full:"1f9d1-1f3fc-200d-1f4bb",shortnames:[":technologist_medium_light_skin_tone:"],category:"people"},":technologist_tone3:":{uc_base:"1f9d1-1f3fd-1f4bb",uc_full:"1f9d1-1f3fd-200d-1f4bb",shortnames:[":technologist_medium_skin_tone:"],category:"people"},":technologist_tone4:":{uc_base:"1f9d1-1f3fe-1f4bb",uc_full:"1f9d1-1f3fe-200d-1f4bb",shortnames:[":technologist_medium_dark_skin_tone:"],category:"people"},":technologist_tone5:":{uc_base:"1f9d1-1f3ff-1f4bb",uc_full:"1f9d1-1f3ff-200d-1f4bb",shortnames:[":technologist_dark_skin_tone:"],category:"people"},":woman_artist_tone1:":{uc_base:"1f469-1f3fb-1f3a8",uc_full:"1f469-1f3fb-200d-1f3a8",shortnames:[":woman_artist_light_skin_tone:"],category:"people"},":woman_artist_tone2:":{uc_base:"1f469-1f3fc-1f3a8",uc_full:"1f469-1f3fc-200d-1f3a8",shortnames:[":woman_artist_medium_light_skin_tone:"],category:"people"},":woman_artist_tone3:":{uc_base:"1f469-1f3fd-1f3a8",uc_full:"1f469-1f3fd-200d-1f3a8",shortnames:[":woman_artist_medium_skin_tone:"],category:"people"},":woman_artist_tone4:":{uc_base:"1f469-1f3fe-1f3a8",uc_full:"1f469-1f3fe-200d-1f3a8",shortnames:[":woman_artist_medium_dark_skin_tone:"],category:"people"},":woman_artist_tone5:":{uc_base:"1f469-1f3ff-1f3a8",uc_full:"1f469-1f3ff-200d-1f3a8",shortnames:[":woman_artist_dark_skin_tone:"],category:"people"},":woman_astronaut_tone1:":{uc_base:"1f469-1f3fb-1f680",uc_full:"Line truncated
|
||||
shortnames:[":man_facepalming_medium_light_skin_tone:"],category:"people"},":man_facepalming_tone3:":{uc_base:"1f926-1f3fd-2642",uc_full:"1f926-1f3fd-200d-2642-fe0f",shortnames:[":man_facepalming_medium_skin_tone:"],category:"people"},":man_facepalming_tone4:":{uc_base:"1f926-1f3fe-2642",uc_full:"1f926-1f3fe-200d-2642-fe0f",shortnames:[":man_facepalming_medium_dark_skin_tone:"],category:"people"},":man_facepalming_tone5:":{uc_base:"1f926-1f3ff-2642",uc_full:"1f926-1f3ff-200d-2642-fe0f",shortnames:[":man_facepalming_dark_skin_tone:"],category:"people"},":man_fairy_tone1:":{uc_base:"1f9da-1f3fb-2642",uc_full:"1f9da-1f3fb-200d-2642-fe0f",shortnames:[":man_fairy_light_skin_tone:"],category:"people"},":man_fairy_tone2:":{uc_base:"1f9da-1f3fc-2642",uc_full:"1f9da-1f3fc-200d-2642-fe0f",shortnames:[":man_fairy_medium_light_skin_tone:"],category:"people"},":man_fairy_tone3:":{uc_base:"1f9da-1f3fd-2642",uc_full:"1f9da-1f3fd-200d-2642-fe0f",shortnames:[":man_fairy_medium_skin_tone:"],category:"people"},":man_fairy_tone4:":{uc_base:"1f9da-1f3fe-2642",uc_full:"1f9da-1f3fe-200d-2642-fe0f",shortnames:[":man_fairy_medium_dark_skin_tone:"],category:"people"},":man_fairy_tone5:":{uc_base:"1f9da-1f3ff-2642",uc_full:"1f9da-1f3ff-200d-2642-fe0f",shortnames:[":man_fairy_dark_skin_tone:"],category:"people"},":man_frowning_tone1:":{uc_base:"1f64d-1f3fb-2642",uc_full:"1f64d-1f3fb-200d-2642-fe0f",shortnames:[":man_frowning_light_skin_tone:"],category:"people"},":man_frowning_tone2:":{uc_base:"1f64d-1f3fc-2642",uc_full:"1f64d-1f3fc-200d-2642-fe0f",shortnames:[":man_frowning_medium_light_skin_tone:"],category:"people"},":man_frowning_tone3:":{uc_base:"1f64d-1f3fd-2642",uc_full:"1f64d-1f3fd-200d-2642-fe0f",shortnames:[":man_frowning_medium_skin_tone:"],category:"people"},":man_frowning_tone4:":{uc_base:"1f64d-1f3fe-2642",uc_full:"1f64d-1f3fe-200d-2642-fe0f",shortnames:[":man_frowning_medium_dark_skin_tone:"],category:"people"},":man_frowning_tone5:":{uc_base:"1f64d-1f3ff-2642",uc_full:"1f64d-1f3ff-200d-2642-fe0f",shortnames:[":man_frowning_dark_skin_tone:"],category:"people"},":man_gesturing_no_tone1:":{uc_base:"1f645-1f3fb-2642",uc_full:"1f645-1f3fb-200d-2642-fe0f",shortnames:[":man_gesturing_no_light_skin_tone:"],category:"people"},":man_gesturing_no_tone2:":{uc_base:"1f645-1f3fc-2642",uc_full:"1f645-1f3fc-200d-2642-fe0f",shortnames:[":man_gesturing_no_medium_light_skin_tone:"],category:"people"},":man_gesturing_no_tone3:":{uc_base:"1f645-1f3fd-2642",uc_full:"1f645-1f3fd-200d-2642-fe0f",shortnames:[":man_gesturing_no_medium_skin_tone:"],category:"people"},":man_gesturing_no_tone4:":{uc_base:"1f645-1f3fe-2642",uc_full:"1f645-1f3fe-200d-2642-fe0f",shortnames:[":man_gesturing_no_medium_dark_skin_tone:"],category:"people"},":man_gesturing_no_tone5:":{uc_base:"1f645-1f3ff-2642",uc_full:"1f645-1f3ff-200d-2642-fe0f",shortnames:[":man_gesturing_no_dark_skin_tone:"],category:"people"},":man_gesturing_ok_tone1:":{uc_base:"1f646-1f3fb-2642",uc_full:"1f646-1f3fb-200d-2642-fe0f",shortnames:[":man_gesturing_ok_light_skin_tone:"],category:"people"},":man_gesturing_ok_tone2:":{uc_base:"1f646-1f3fc-2642",uc_full:"1f646-1f3fc-200d-2642-fe0f",shortnames:[":man_gesturing_ok_medium_light_skin_tone:"],category:"people"},":man_gesturing_ok_tone3:":{uc_base:"1f646-1f3fd-2642",uc_full:"1f646-1f3fd-200d-2642-fe0f",shortnames:[":man_gesturing_ok_medium_skin_tone:"],category:"people"},":man_gesturing_ok_tone4:":{uc_base:"1f646-1f3fe-2642",uc_full:"1f646-1f3fe-200d-2642-fe0f",shortnames:[":man_gesturing_ok_medium_dark_skin_tone:"],category:"people"},":man_gesturing_ok_tone5:":{uc_base:"1f646-1f3ff-2642",uc_full:"1f646-1f3ff-200d-2642-fe0f",shortnames:[":man_gesturing_ok_dark_skin_tone:"],category:"people"},":man_getting_face_massage_tone1:":{uc_base:"1f486-1f3fb-2642",uc_full:"1f486-1f3fb-200d-2642-fe0f",shortnames:[":man_getting_face_massage_light_skin_tone:"],category:"people"},":man_getting_face_massage_tone2:":{uc_base:"1f486-1f3fc-2642",uc_full:"1f486-1f3fc-200d-2642-fe0f",shortnames:[":man_getting_face_massage_medium_light_skin_tone:"],category:"people"},":man_getting_face_massage_tone3:":{uc_base:"1f486-1f3fd-2642",uc_full:"1f486-1f3fd-200d-2642-fe0f",shortnames:[":man_getting_face_massage_medium_skin_tone:"],category:"people"},":man_getting_face_massage_tone4:":{uc_base:"1f486-1f3fe-2642",uc_full:"1f486-1f3fe-200d-2642-fe0f",shortnames:[":man_getting_face_massage_medium_dark_skin_tone:"],category:"people"},":man_getting_face_massage_tone5:":{uc_base:"1f486-1f3ff-2642",uc_full:"1f486-1f3ff-200d-2642-fe0f",shortnames:[":man_getting_face_massage_dark_skin_tone:"],category:"people"},":man_getting_haircut_tone1:":{uc_base:"1f487-1f3fb-2642",uc_full:"1f487-1f3fb-200d-2642-fe0f",shortnames:[":man_getting_haircut_light_skin_tone:"],category:"people"},":man_getting_haircut_tone2:":{uc_base:"1f487-1f3fc-2642",uc_full:"1f487-1f3fc-200d-2642-fe0f",shortnames:[":man_getting_haircut_medium_light_skin_tone:"],category:"people"},":man_getting_haircut_tone3:":{uc_base:"Line truncated
|
||||
shortnames:[":merman_medium_light_skin_tone:"],category:"people"},":merman_tone3:":{uc_base:"1f9dc-1f3fd-2642",uc_full:"1f9dc-1f3fd-200d-2642-fe0f",shortnames:[":merman_medium_skin_tone:"],category:"people"},":merman_tone4:":{uc_base:"1f9dc-1f3fe-2642",uc_full:"1f9dc-1f3fe-200d-2642-fe0f",shortnames:[":merman_medium_dark_skin_tone:"],category:"people"},":merman_tone5:":{uc_base:"1f9dc-1f3ff-2642",uc_full:"1f9dc-1f3ff-200d-2642-fe0f",shortnames:[":merman_dark_skin_tone:"],category:"people"},":pilot_tone1:":{uc_base:"1f9d1-1f3fb-2708",uc_full:"1f9d1-1f3fb-200d-2708-fe0f",shortnames:[":pilot_light_skin_tone:"],category:"people"},":pilot_tone2:":{uc_base:"1f9d1-1f3fc-2708",uc_full:"1f9d1-1f3fc-200d-2708-fe0f",shortnames:[":pilot_medium_light_skin_tone:"],category:"people"},":pilot_tone3:":{uc_base:"1f9d1-1f3fd-2708",uc_full:"1f9d1-1f3fd-200d-2708-fe0f",shortnames:[":pilot_medium_skin_tone:"],category:"people"},":pilot_tone4:":{uc_base:"1f9d1-1f3fe-2708",uc_full:"1f9d1-1f3fe-200d-2708-fe0f",shortnames:[":pilot_medium_dark_skin_tone:"],category:"people"},":pilot_tone5:":{uc_base:"1f9d1-1f3ff-2708",uc_full:"1f9d1-1f3ff-200d-2708-fe0f",shortnames:[":pilot_dark_skin_tone:"],category:"people"},":woman_biking_tone1:":{uc_base:"1f6b4-1f3fb-2640",uc_full:"1f6b4-1f3fb-200d-2640-fe0f",shortnames:[":woman_biking_light_skin_tone:"],category:"activity"},":woman_biking_tone2:":{uc_base:"1f6b4-1f3fc-2640",uc_full:"1f6b4-1f3fc-200d-2640-fe0f",shortnames:[":woman_biking_medium_light_skin_tone:"],category:"activity"},":woman_biking_tone3:":{uc_base:"1f6b4-1f3fd-2640",uc_full:"1f6b4-1f3fd-200d-2640-fe0f",shortnames:[":woman_biking_medium_skin_tone:"],category:"activity"},":woman_biking_tone4:":{uc_base:"1f6b4-1f3fe-2640",uc_full:"1f6b4-1f3fe-200d-2640-fe0f",shortnames:[":woman_biking_medium_dark_skin_tone:"],category:"activity"},":woman_biking_tone5:":{uc_base:"1f6b4-1f3ff-2640",uc_full:"1f6b4-1f3ff-200d-2640-fe0f",shortnames:[":woman_biking_dark_skin_tone:"],category:"activity"},":woman_bowing_tone1:":{uc_base:"1f647-1f3fb-2640",uc_full:"1f647-1f3fb-200d-2640-fe0f",shortnames:[":woman_bowing_light_skin_tone:"],category:"people"},":woman_bowing_tone2:":{uc_base:"1f647-1f3fc-2640",uc_full:"1f647-1f3fc-200d-2640-fe0f",shortnames:[":woman_bowing_medium_light_skin_tone:"],category:"people"},":woman_bowing_tone3:":{uc_base:"1f647-1f3fd-2640",uc_full:"1f647-1f3fd-200d-2640-fe0f",shortnames:[":woman_bowing_medium_skin_tone:"],category:"people"},":woman_bowing_tone4:":{uc_base:"1f647-1f3fe-2640",uc_full:"1f647-1f3fe-200d-2640-fe0f",shortnames:[":woman_bowing_medium_dark_skin_tone:"],category:"people"},":woman_bowing_tone5:":{uc_base:"1f647-1f3ff-2640",uc_full:"1f647-1f3ff-200d-2640-fe0f",shortnames:[":woman_bowing_dark_skin_tone:"],category:"people"},":woman_cartwheeling_tone1:":{uc_base:"1f938-1f3fb-2640",uc_full:"1f938-1f3fb-200d-2640-fe0f",shortnames:[":woman_cartwheeling_light_skin_tone:"],category:"activity"},":woman_cartwheeling_tone2:":{uc_base:"1f938-1f3fc-2640",uc_full:"1f938-1f3fc-200d-2640-fe0f",shortnames:[":woman_cartwheeling_medium_light_skin_tone:"],category:"activity"},":woman_cartwheeling_tone3:":{uc_base:"1f938-1f3fd-2640",uc_full:"1f938-1f3fd-200d-2640-fe0f",shortnames:[":woman_cartwheeling_medium_skin_tone:"],category:"activity"},":woman_cartwheeling_tone4:":{uc_base:"1f938-1f3fe-2640",uc_full:"1f938-1f3fe-200d-2640-fe0f",shortnames:[":woman_cartwheeling_medium_dark_skin_tone:"],category:"activity"},":woman_cartwheeling_tone5:":{uc_base:"1f938-1f3ff-2640",uc_full:"1f938-1f3ff-200d-2640-fe0f",shortnames:[":woman_cartwheeling_dark_skin_tone:"],category:"activity"},":woman_climbing_tone1:":{uc_base:"1f9d7-1f3fb-2640",uc_full:"1f9d7-1f3fb-200d-2640-fe0f",shortnames:[":woman_climbing_light_skin_tone:"],category:"activity"},":woman_climbing_tone2:":{uc_base:"1f9d7-1f3fc-2640",uc_full:"1f9d7-1f3fc-200d-2640-fe0f",shortnames:[":woman_climbing_medium_light_skin_tone:"],category:"activity"},":woman_climbing_tone3:":{uc_base:"1f9d7-1f3fd-2640",uc_full:"1f9d7-1f3fd-200d-2640-fe0f",shortnames:[":woman_climbing_medium_skin_tone:"],category:"activity"},":woman_climbing_tone4:":{uc_base:"1f9d7-1f3fe-2640",uc_full:"1f9d7-1f3fe-200d-2640-fe0f",shortnames:[":woman_climbing_medium_dark_skin_tone:"],category:"activity"},":woman_climbing_tone5:":{uc_base:"1f9d7-1f3ff-2640",uc_full:"1f9d7-1f3ff-200d-2640-fe0f",shortnames:[":woman_climbing_dark_skin_tone:"],category:"activity"},":woman_construction_worker_tone1:":{uc_base:"1f477-1f3fb-2640",uc_full:"1f477-1f3fb-200d-2640-fe0f",shortnames:[":woman_construction_worker_light_skin_tone:"],category:"people"},":woman_construction_worker_tone2:":{uc_base:"1f477-1f3fc-2640",uc_full:"1f477-1f3fc-200d-2640-fe0f",shortnames:[":woman_construction_worker_medium_light_skin_tone:"],category:"people"},":woman_construction_worker_tone3:":{uc_base:"1f477-1f3fd-2640",uc_full:"1f477-1f3fd-200d-2640-fe0f",shortnames:[":woman_construction_worker_medium_skin_tone:"],category:"people"},":woman_construction_worker_tLine truncated
|
||||
category:"people"},":woman_supervillain_tone3:":{uc_base:"1f9b9-1f3fd-2640",uc_full:"1f9b9-1f3fd-200d-2640-fe0f",shortnames:[":woman_supervillain_medium_skin_tone:"],category:"people"},":woman_supervillain_tone4:":{uc_base:"1f9b9-1f3fe-2640",uc_full:"1f9b9-1f3fe-200d-2640-fe0f",shortnames:[":woman_supervillain_medium_dark_skin_tone:"],category:"people"},":woman_supervillain_tone5:":{uc_base:"1f9b9-1f3ff-2640",uc_full:"1f9b9-1f3ff-200d-2640-fe0f",shortnames:[":woman_supervillain_dark_skin_tone:"],category:"people"},":woman_surfing_tone1:":{uc_base:"1f3c4-1f3fb-2640",uc_full:"1f3c4-1f3fb-200d-2640-fe0f",shortnames:[":woman_surfing_light_skin_tone:"],category:"activity"},":woman_surfing_tone2:":{uc_base:"1f3c4-1f3fc-2640",uc_full:"1f3c4-1f3fc-200d-2640-fe0f",shortnames:[":woman_surfing_medium_light_skin_tone:"],category:"activity"},":woman_surfing_tone3:":{uc_base:"1f3c4-1f3fd-2640",uc_full:"1f3c4-1f3fd-200d-2640-fe0f",shortnames:[":woman_surfing_medium_skin_tone:"],category:"activity"},":woman_surfing_tone4:":{uc_base:"1f3c4-1f3fe-2640",uc_full:"1f3c4-1f3fe-200d-2640-fe0f",shortnames:[":woman_surfing_medium_dark_skin_tone:"],category:"activity"},":woman_surfing_tone5:":{uc_base:"1f3c4-1f3ff-2640",uc_full:"1f3c4-1f3ff-200d-2640-fe0f",shortnames:[":woman_surfing_dark_skin_tone:"],category:"activity"},":woman_swimming_tone1:":{uc_base:"1f3ca-1f3fb-2640",uc_full:"1f3ca-1f3fb-200d-2640-fe0f",shortnames:[":woman_swimming_light_skin_tone:"],category:"activity"},":woman_swimming_tone2:":{uc_base:"1f3ca-1f3fc-2640",uc_full:"1f3ca-1f3fc-200d-2640-fe0f",shortnames:[":woman_swimming_medium_light_skin_tone:"],category:"activity"},":woman_swimming_tone3:":{uc_base:"1f3ca-1f3fd-2640",uc_full:"1f3ca-1f3fd-200d-2640-fe0f",shortnames:[":woman_swimming_medium_skin_tone:"],category:"activity"},":woman_swimming_tone4:":{uc_base:"1f3ca-1f3fe-2640",uc_full:"1f3ca-1f3fe-200d-2640-fe0f",shortnames:[":woman_swimming_medium_dark_skin_tone:"],category:"activity"},":woman_swimming_tone5:":{uc_base:"1f3ca-1f3ff-2640",uc_full:"1f3ca-1f3ff-200d-2640-fe0f",shortnames:[":woman_swimming_dark_skin_tone:"],category:"activity"},":woman_tipping_hand_tone1:":{uc_base:"1f481-1f3fb-2640",uc_full:"1f481-1f3fb-200d-2640-fe0f",shortnames:[":woman_tipping_hand_light_skin_tone:"],category:"people"},":woman_tipping_hand_tone2:":{uc_base:"1f481-1f3fc-2640",uc_full:"1f481-1f3fc-200d-2640-fe0f",shortnames:[":woman_tipping_hand_medium_light_skin_tone:"],category:"people"},":woman_tipping_hand_tone3:":{uc_base:"1f481-1f3fd-2640",uc_full:"1f481-1f3fd-200d-2640-fe0f",shortnames:[":woman_tipping_hand_medium_skin_tone:"],category:"people"},":woman_tipping_hand_tone4:":{uc_base:"1f481-1f3fe-2640",uc_full:"1f481-1f3fe-200d-2640-fe0f",shortnames:[":woman_tipping_hand_medium_dark_skin_tone:"],category:"people"},":woman_tipping_hand_tone5:":{uc_base:"1f481-1f3ff-2640",uc_full:"1f481-1f3ff-200d-2640-fe0f",shortnames:[":woman_tipping_hand_dark_skin_tone:"],category:"people"},":woman_tone1_beard:":{uc_base:"1f9d4-1f3fb-2640",uc_full:"1f9d4-1f3fb-200d-2640-fe0f",shortnames:[":woman_light_skin_tone_beard:"],category:"people"},":woman_tone2_beard:":{uc_base:"1f9d4-1f3fc-2640",uc_full:"1f9d4-1f3fc-200d-2640-fe0f",shortnames:[":woman_medium_light_skin_tone_beard:"],category:"people"},":woman_tone3_beard:":{uc_base:"1f9d4-1f3fd-2640",uc_full:"1f9d4-1f3fd-200d-2640-fe0f",shortnames:[":woman_medium_skin_tone_beard:"],category:"people"},":woman_tone4_beard:":{uc_base:"1f9d4-1f3fe-2640",uc_full:"1f9d4-1f3fe-200d-2640-fe0f",shortnames:[":woman_medium_dark_skin_tone_beard:"],category:"people"},":woman_tone5_beard:":{uc_base:"1f9d4-1f3ff-2640",uc_full:"1f9d4-1f3ff-200d-2640-fe0f",shortnames:[":woman_dark_skin_tone_beard:"],category:"people"},":woman_vampire_tone1:":{uc_base:"1f9db-1f3fb-2640",uc_full:"1f9db-1f3fb-200d-2640-fe0f",shortnames:[":woman_vampire_light_skin_tone:"],category:"people"},":woman_vampire_tone2:":{uc_base:"1f9db-1f3fc-2640",uc_full:"1f9db-1f3fc-200d-2640-fe0f",shortnames:[":woman_vampire_medium_light_skin_tone:"],category:"people"},":woman_vampire_tone3:":{uc_base:"1f9db-1f3fd-2640",uc_full:"1f9db-1f3fd-200d-2640-fe0f",shortnames:[":woman_vampire_medium_skin_tone:"],category:"people"},":woman_vampire_tone4:":{uc_base:"1f9db-1f3fe-2640",uc_full:"1f9db-1f3fe-200d-2640-fe0f",shortnames:[":woman_vampire_medium_dark_skin_tone:"],category:"people"},":woman_vampire_tone5:":{uc_base:"1f9db-1f3ff-2640",uc_full:"1f9db-1f3ff-200d-2640-fe0f",shortnames:[":woman_vampire_dark_skin_tone:"],category:"people"},":woman_walking_tone1:":{uc_base:"1f6b6-1f3fb-2640",uc_full:"1f6b6-1f3fb-200d-2640-fe0f",shortnames:[":woman_walking_light_skin_tone:"],category:"people"},":woman_walking_tone2:":{uc_base:"1f6b6-1f3fc-2640",uc_full:"1f6b6-1f3fc-200d-2640-fe0f",shortnames:[":woman_walking_medium_light_skin_tone:"],category:"people"},":woman_walking_tone3:":{uc_base:"1f6b6-1f3fd-2640",uc_full:"1f6b6-1f3fd-200d-2640-fe0f",shortnames:[":woman_walking_medium_skin_tone:"],category:"people"},":womaLine truncated
|
||||
":flag_hk:":{uc_base:"1f1ed-1f1f0",uc_full:"1f1ed-1f1f0",shortnames:[":hk:"],category:"flags"},":flag_hm:":{uc_base:"1f1ed-1f1f2",uc_full:"1f1ed-1f1f2",shortnames:[":hm:"],category:"flags"},":flag_hn:":{uc_base:"1f1ed-1f1f3",uc_full:"1f1ed-1f1f3",shortnames:[":hn:"],category:"flags"},":flag_hr:":{uc_base:"1f1ed-1f1f7",uc_full:"1f1ed-1f1f7",shortnames:[":hr:"],category:"flags"},":flag_ht:":{uc_base:"1f1ed-1f1f9",uc_full:"1f1ed-1f1f9",shortnames:[":ht:"],category:"flags"},":flag_hu:":{uc_base:"1f1ed-1f1fa",uc_full:"1f1ed-1f1fa",shortnames:[":hu:"],category:"flags"},":flag_ic:":{uc_base:"1f1ee-1f1e8",uc_full:"1f1ee-1f1e8",shortnames:[":ic:"],category:"flags"},":flag_id:":{uc_base:"1f1ee-1f1e9",uc_full:"1f1ee-1f1e9",shortnames:[":indonesia:"],category:"flags"},":flag_ie:":{uc_base:"1f1ee-1f1ea",uc_full:"1f1ee-1f1ea",shortnames:[":ie:"],category:"flags"},":flag_il:":{uc_base:"1f1ee-1f1f1",uc_full:"1f1ee-1f1f1",shortnames:[":il:"],category:"flags"},":flag_im:":{uc_base:"1f1ee-1f1f2",uc_full:"1f1ee-1f1f2",shortnames:[":im:"],category:"flags"},":flag_in:":{uc_base:"1f1ee-1f1f3",uc_full:"1f1ee-1f1f3",shortnames:[":in:"],category:"flags"},":flag_io:":{uc_base:"1f1ee-1f1f4",uc_full:"1f1ee-1f1f4",shortnames:[":io:"],category:"flags"},":flag_iq:":{uc_base:"1f1ee-1f1f6",uc_full:"1f1ee-1f1f6",shortnames:[":iq:"],category:"flags"},":flag_ir:":{uc_base:"1f1ee-1f1f7",uc_full:"1f1ee-1f1f7",shortnames:[":ir:"],category:"flags"},":flag_is:":{uc_base:"1f1ee-1f1f8",uc_full:"1f1ee-1f1f8",shortnames:[":is:"],category:"flags"},":flag_it:":{uc_base:"1f1ee-1f1f9",uc_full:"1f1ee-1f1f9",shortnames:[":it:"],category:"flags"},":flag_je:":{uc_base:"1f1ef-1f1ea",uc_full:"1f1ef-1f1ea",shortnames:[":je:"],category:"flags"},":flag_jm:":{uc_base:"1f1ef-1f1f2",uc_full:"1f1ef-1f1f2",shortnames:[":jm:"],category:"flags"},":flag_jo:":{uc_base:"1f1ef-1f1f4",uc_full:"1f1ef-1f1f4",shortnames:[":jo:"],category:"flags"},":flag_jp:":{uc_base:"1f1ef-1f1f5",uc_full:"1f1ef-1f1f5",shortnames:[":jp:"],category:"flags"},":flag_ke:":{uc_base:"1f1f0-1f1ea",uc_full:"1f1f0-1f1ea",shortnames:[":ke:"],category:"flags"},":flag_kg:":{uc_base:"1f1f0-1f1ec",uc_full:"1f1f0-1f1ec",shortnames:[":kg:"],category:"flags"},":flag_kh:":{uc_base:"1f1f0-1f1ed",uc_full:"1f1f0-1f1ed",shortnames:[":kh:"],category:"flags"},":flag_ki:":{uc_base:"1f1f0-1f1ee",uc_full:"1f1f0-1f1ee",shortnames:[":ki:"],category:"flags"},":flag_km:":{uc_base:"1f1f0-1f1f2",uc_full:"1f1f0-1f1f2",shortnames:[":km:"],category:"flags"},":flag_kn:":{uc_base:"1f1f0-1f1f3",uc_full:"1f1f0-1f1f3",shortnames:[":kn:"],category:"flags"},":flag_kp:":{uc_base:"1f1f0-1f1f5",uc_full:"1f1f0-1f1f5",shortnames:[":kp:"],category:"flags"},":flag_kr:":{uc_base:"1f1f0-1f1f7",uc_full:"1f1f0-1f1f7",shortnames:[":kr:"],category:"flags"},":flag_kw:":{uc_base:"1f1f0-1f1fc",uc_full:"1f1f0-1f1fc",shortnames:[":kw:"],category:"flags"},":flag_ky:":{uc_base:"1f1f0-1f1fe",uc_full:"1f1f0-1f1fe",shortnames:[":ky:"],category:"flags"},":flag_kz:":{uc_base:"1f1f0-1f1ff",uc_full:"1f1f0-1f1ff",shortnames:[":kz:"],category:"flags"},":flag_la:":{uc_base:"1f1f1-1f1e6",uc_full:"1f1f1-1f1e6",shortnames:[":la:"],category:"flags"},":flag_lb:":{uc_base:"1f1f1-1f1e7",uc_full:"1f1f1-1f1e7",shortnames:[":lb:"],category:"flags"},":flag_lc:":{uc_base:"1f1f1-1f1e8",uc_full:"1f1f1-1f1e8",shortnames:[":lc:"],category:"flags"},":flag_li:":{uc_base:"1f1f1-1f1ee",uc_full:"1f1f1-1f1ee",shortnames:[":li:"],category:"flags"},":flag_lk:":{uc_base:"1f1f1-1f1f0",uc_full:"1f1f1-1f1f0",shortnames:[":lk:"],category:"flags"},":flag_lr:":{uc_base:"1f1f1-1f1f7",uc_full:"1f1f1-1f1f7",shortnames:[":lr:"],category:"flags"},":flag_ls:":{uc_base:"1f1f1-1f1f8",uc_full:"1f1f1-1f1f8",shortnames:[":ls:"],category:"flags"},":flag_lt:":{uc_base:"1f1f1-1f1f9",uc_full:"1f1f1-1f1f9",shortnames:[":lt:"],category:"flags"},":flag_lu:":{uc_base:"1f1f1-1f1fa",uc_full:"1f1f1-1f1fa",shortnames:[":lu:"],category:"flags"},":flag_lv:":{uc_base:"1f1f1-1f1fb",uc_full:"1f1f1-1f1fb",shortnames:[":lv:"],category:"flags"},":flag_ly:":{uc_base:"1f1f1-1f1fe",uc_full:"1f1f1-1f1fe",shortnames:[":ly:"],category:"flags"},":flag_ma:":{uc_base:"1f1f2-1f1e6",uc_full:"1f1f2-1f1e6",shortnames:[":ma:"],category:"flags"},":flag_mc:":{uc_base:"1f1f2-1f1e8",uc_full:"1f1f2-1f1e8",shortnames:[":mc:"],category:"flags"},":flag_md:":{uc_base:"1f1f2-1f1e9",uc_full:"1f1f2-1f1e9",shortnames:[":md:"],category:"flags"},":flag_me:":{uc_base:"1f1f2-1f1ea",uc_full:"1f1f2-1f1ea",shortnames:[":me:"],category:"flags"},":flag_mf:":{uc_base:"1f1f2-1f1eb",uc_full:"1f1f2-1f1eb",shortnames:[":mf:"],category:"flags"},":flag_mg:":{uc_base:"1f1f2-1f1ec",uc_full:"1f1f2-1f1ec",shortnames:[":mg:"],category:"flags"},":flag_mh:":{uc_base:"1f1f2-1f1ed",uc_full:"1f1f2-1f1ed",shortnames:[":mh:"],category:"flags"},":flag_mk:":{uc_base:"1f1f2-1f1f0",uc_full:"1f1f2-1f1f0",shortnames:[":mk:"],category:"flags"},":flag_ml:":{uc_base:"1f1f2-1f1f1",uc_full:"1f1f2-1f1f1",shortnames:[":ml:"],category:"flags"},":flag_mm:":{uc_base:"1f1f2-1f1f2",uc_full:"1f1f2-1fLine truncated
|
||||
uc_base:"1f44c-1f3ff",uc_full:"1f44c-1f3ff",shortnames:[],category:"people"},":older_adult_tone1:":{uc_base:"1f9d3-1f3fb",uc_full:"1f9d3-1f3fb",shortnames:[":older_adult_light_skin_tone:"],category:"people"},":older_adult_tone2:":{uc_base:"1f9d3-1f3fc",uc_full:"1f9d3-1f3fc",shortnames:[":older_adult_medium_light_skin_tone:"],category:"people"},":older_adult_tone3:":{uc_base:"1f9d3-1f3fd",uc_full:"1f9d3-1f3fd",shortnames:[":older_adult_medium_skin_tone:"],category:"people"},":older_adult_tone4:":{uc_base:"1f9d3-1f3fe",uc_full:"1f9d3-1f3fe",shortnames:[":older_adult_medium_dark_skin_tone:"],category:"people"},":older_adult_tone5:":{uc_base:"1f9d3-1f3ff",uc_full:"1f9d3-1f3ff",shortnames:[":older_adult_dark_skin_tone:"],category:"people"},":older_man_tone1:":{uc_base:"1f474-1f3fb",uc_full:"1f474-1f3fb",shortnames:[],category:"people"},":older_man_tone2:":{uc_base:"1f474-1f3fc",uc_full:"1f474-1f3fc",shortnames:[],category:"people"},":older_man_tone3:":{uc_base:"1f474-1f3fd",uc_full:"1f474-1f3fd",shortnames:[],category:"people"},":older_man_tone4:":{uc_base:"1f474-1f3fe",uc_full:"1f474-1f3fe",shortnames:[],category:"people"},":older_man_tone5:":{uc_base:"1f474-1f3ff",uc_full:"1f474-1f3ff",shortnames:[],category:"people"},":older_woman_tone1:":{uc_base:"1f475-1f3fb",uc_full:"1f475-1f3fb",shortnames:[":grandma_tone1:"],category:"people"},":older_woman_tone2:":{uc_base:"1f475-1f3fc",uc_full:"1f475-1f3fc",shortnames:[":grandma_tone2:"],category:"people"},":older_woman_tone3:":{uc_base:"1f475-1f3fd",uc_full:"1f475-1f3fd",shortnames:[":grandma_tone3:"],category:"people"},":older_woman_tone4:":{uc_base:"1f475-1f3fe",uc_full:"1f475-1f3fe",shortnames:[":grandma_tone4:"],category:"people"},":older_woman_tone5:":{uc_base:"1f475-1f3ff",uc_full:"1f475-1f3ff",shortnames:[":grandma_tone5:"],category:"people"},":open_hands_tone1:":{uc_base:"1f450-1f3fb",uc_full:"1f450-1f3fb",shortnames:[],category:"people"},":open_hands_tone2:":{uc_base:"1f450-1f3fc",uc_full:"1f450-1f3fc",shortnames:[],category:"people"},":open_hands_tone3:":{uc_base:"1f450-1f3fd",uc_full:"1f450-1f3fd",shortnames:[],category:"people"},":open_hands_tone4:":{uc_base:"1f450-1f3fe",uc_full:"1f450-1f3fe",shortnames:[],category:"people"},":open_hands_tone5:":{uc_base:"1f450-1f3ff",uc_full:"1f450-1f3ff",shortnames:[],category:"people"},":palms_up_together_tone1:":{uc_base:"1f932-1f3fb",uc_full:"1f932-1f3fb",shortnames:[":palms_up_together_light_skin_tone:"],category:"people"},":palms_up_together_tone2:":{uc_base:"1f932-1f3fc",uc_full:"1f932-1f3fc",shortnames:[":palms_up_together_medium_light_skin_tone:"],category:"people"},":palms_up_together_tone3:":{uc_base:"1f932-1f3fd",uc_full:"1f932-1f3fd",shortnames:[":palms_up_together_medium_skin_tone:"],category:"people"},":palms_up_together_tone4:":{uc_base:"1f932-1f3fe",uc_full:"1f932-1f3fe",shortnames:[":palms_up_together_medium_dark_skin_tone:"],category:"people"},":palms_up_together_tone5:":{uc_base:"1f932-1f3ff",uc_full:"1f932-1f3ff",shortnames:[":palms_up_together_dark_skin_tone:"],category:"people"},":person_bald:":{uc_base:"1f9d1-1f9b2",uc_full:"1f9d1-200d-1f9b2",shortnames:[],category:"people"},":person_biking_tone1:":{uc_base:"1f6b4-1f3fb",uc_full:"1f6b4-1f3fb",shortnames:[":bicyclist_tone1:"],category:"activity"},":person_biking_tone2:":{uc_base:"1f6b4-1f3fc",uc_full:"1f6b4-1f3fc",shortnames:[":bicyclist_tone2:"],category:"activity"},":person_biking_tone3:":{uc_base:"1f6b4-1f3fd",uc_full:"1f6b4-1f3fd",shortnames:[":bicyclist_tone3:"],category:"activity"},":person_biking_tone4:":{uc_base:"1f6b4-1f3fe",uc_full:"1f6b4-1f3fe",shortnames:[":bicyclist_tone4:"],category:"activity"},":person_biking_tone5:":{uc_base:"1f6b4-1f3ff",uc_full:"1f6b4-1f3ff",shortnames:[":bicyclist_tone5:"],category:"activity"},":person_bowing_tone1:":{uc_base:"1f647-1f3fb",uc_full:"1f647-1f3fb",shortnames:[":bow_tone1:"],category:"people"},":person_bowing_tone2:":{uc_base:"1f647-1f3fc",uc_full:"1f647-1f3fc",shortnames:[":bow_tone2:"],category:"people"},":person_bowing_tone3:":{uc_base:"1f647-1f3fd",uc_full:"1f647-1f3fd",shortnames:[":bow_tone3:"],category:"people"},":person_bowing_tone4:":{uc_base:"1f647-1f3fe",uc_full:"1f647-1f3fe",shortnames:[":bow_tone4:"],category:"people"},":person_bowing_tone5:":{uc_base:"1f647-1f3ff",uc_full:"1f647-1f3ff",shortnames:[":bow_tone5:"],category:"people"},":person_climbing_tone1:":{uc_base:"1f9d7-1f3fb",uc_full:"1f9d7-1f3fb",shortnames:[":person_climbing_light_skin_tone:"],category:"activity"},":person_climbing_tone2:":{uc_base:"1f9d7-1f3fc",uc_full:"1f9d7-1f3fc",shortnames:[":person_climbing_medium_light_skin_tone:"],category:"activity"},":person_climbing_tone3:":{uc_base:"1f9d7-1f3fd",uc_full:"1f9d7-1f3fd",shortnames:[":person_climbing_medium_skin_tone:"],category:"activity"},":person_climbing_tone4:":{uc_base:"1f9d7-1f3fe",uc_full:"1f9d7-1f3fe",shortnames:[":person_climbing_medium_dark_skin_tone:"],category:"activity"},":person_climbing_tone5:":{uc_base:"1f9d7-1f3ff",uc_full:"1f9d7-1f3ff",shortnames:[":persLine truncated
|
||||
uc_full:"1f44a-1f3fe",shortnames:[],category:"people"},":punch_tone5:":{uc_base:"1f44a-1f3ff",uc_full:"1f44a-1f3ff",shortnames:[],category:"people"},":rainbow_flag:":{uc_base:"1f3f3-1f308",uc_full:"1f3f3-fe0f-200d-1f308",shortnames:[":gay_pride_flag:"],category:"flags"},":raised_back_of_hand_tone1:":{uc_base:"1f91a-1f3fb",uc_full:"1f91a-1f3fb",shortnames:[":back_of_hand_tone1:"],category:"people"},":raised_back_of_hand_tone2:":{uc_base:"1f91a-1f3fc",uc_full:"1f91a-1f3fc",shortnames:[":back_of_hand_tone2:"],category:"people"},":raised_back_of_hand_tone3:":{uc_base:"1f91a-1f3fd",uc_full:"1f91a-1f3fd",shortnames:[":back_of_hand_tone3:"],category:"people"},":raised_back_of_hand_tone4:":{uc_base:"1f91a-1f3fe",uc_full:"1f91a-1f3fe",shortnames:[":back_of_hand_tone4:"],category:"people"},":raised_back_of_hand_tone5:":{uc_base:"1f91a-1f3ff",uc_full:"1f91a-1f3ff",shortnames:[":back_of_hand_tone5:"],category:"people"},":raised_hands_tone1:":{uc_base:"1f64c-1f3fb",uc_full:"1f64c-1f3fb",shortnames:[],category:"people"},":raised_hands_tone2:":{uc_base:"1f64c-1f3fc",uc_full:"1f64c-1f3fc",shortnames:[],category:"people"},":raised_hands_tone3:":{uc_base:"1f64c-1f3fd",uc_full:"1f64c-1f3fd",shortnames:[],category:"people"},":raised_hands_tone4:":{uc_base:"1f64c-1f3fe",uc_full:"1f64c-1f3fe",shortnames:[],category:"people"},":raised_hands_tone5:":{uc_base:"1f64c-1f3ff",uc_full:"1f64c-1f3ff",shortnames:[],category:"people"},":right_facing_fist_tone1:":{uc_base:"1f91c-1f3fb",uc_full:"1f91c-1f3fb",shortnames:[":right_fist_tone1:"],category:"people"},":right_facing_fist_tone2:":{uc_base:"1f91c-1f3fc",uc_full:"1f91c-1f3fc",shortnames:[":right_fist_tone2:"],category:"people"},":right_facing_fist_tone3:":{uc_base:"1f91c-1f3fd",uc_full:"1f91c-1f3fd",shortnames:[":right_fist_tone3:"],category:"people"},":right_facing_fist_tone4:":{uc_base:"1f91c-1f3fe",uc_full:"1f91c-1f3fe",shortnames:[":right_fist_tone4:"],category:"people"},":right_facing_fist_tone5:":{uc_base:"1f91c-1f3ff",uc_full:"1f91c-1f3ff",shortnames:[":right_fist_tone5:"],category:"people"},":santa_tone1:":{uc_base:"1f385-1f3fb",uc_full:"1f385-1f3fb",shortnames:[],category:"people"},":santa_tone2:":{uc_base:"1f385-1f3fc",uc_full:"1f385-1f3fc",shortnames:[],category:"people"},":santa_tone3:":{uc_base:"1f385-1f3fd",uc_full:"1f385-1f3fd",shortnames:[],category:"people"},":santa_tone4:":{uc_base:"1f385-1f3fe",uc_full:"1f385-1f3fe",shortnames:[],category:"people"},":santa_tone5:":{uc_base:"1f385-1f3ff",uc_full:"1f385-1f3ff",shortnames:[],category:"people"},":scientist:":{uc_base:"1f9d1-1f52c",uc_full:"1f9d1-200d-1f52c",shortnames:[],category:"people"},":selfie_tone1:":{uc_base:"1f933-1f3fb",uc_full:"1f933-1f3fb",shortnames:[],category:"people"},":selfie_tone2:":{uc_base:"1f933-1f3fc",uc_full:"1f933-1f3fc",shortnames:[],category:"people"},":selfie_tone3:":{uc_base:"1f933-1f3fd",uc_full:"1f933-1f3fd",shortnames:[],category:"people"},":selfie_tone4:":{uc_base:"1f933-1f3fe",uc_full:"1f933-1f3fe",shortnames:[],category:"people"},":selfie_tone5:":{uc_base:"1f933-1f3ff",uc_full:"1f933-1f3ff",shortnames:[],category:"people"},":service_dog:":{uc_base:"1f415-1f9ba",uc_full:"1f415-200d-1f9ba",shortnames:[],category:"nature"},":singer:":{uc_base:"1f9d1-1f3a4",uc_full:"1f9d1-200d-1f3a4",shortnames:[],category:"people"},":snowboarder_tone1:":{uc_base:"1f3c2-1f3fb",uc_full:"1f3c2-1f3fb",shortnames:[":snowboarder_light_skin_tone:"],category:"activity"},":snowboarder_tone2:":{uc_base:"1f3c2-1f3fc",uc_full:"1f3c2-1f3fc",shortnames:[":snowboarder_medium_light_skin_tone:"],category:"activity"},":snowboarder_tone3:":{uc_base:"1f3c2-1f3fd",uc_full:"1f3c2-1f3fd",shortnames:[":snowboarder_medium_skin_tone:"],category:"activity"},":snowboarder_tone4:":{uc_base:"1f3c2-1f3fe",uc_full:"1f3c2-1f3fe",shortnames:[":snowboarder_medium_dark_skin_tone:"],category:"activity"},":snowboarder_tone5:":{uc_base:"1f3c2-1f3ff",uc_full:"1f3c2-1f3ff",shortnames:[":snowboarder_dark_skin_tone:"],category:"activity"},":student:":{uc_base:"1f9d1-1f393",uc_full:"1f9d1-200d-1f393",shortnames:[],category:"people"},":superhero_tone1:":{uc_base:"1f9b8-1f3fb",uc_full:"1f9b8-1f3fb",shortnames:[":superhero_light_skin_tone:"],category:"people"},":superhero_tone2:":{uc_base:"1f9b8-1f3fc",uc_full:"1f9b8-1f3fc",shortnames:[":superhero_medium_light_skin_tone:"],category:"people"},":superhero_tone3:":{uc_base:"1f9b8-1f3fd",uc_full:"1f9b8-1f3fd",shortnames:[":superhero_medium_skin_tone:"],category:"people"},":superhero_tone4:":{uc_base:"1f9b8-1f3fe",uc_full:"1f9b8-1f3fe",shortnames:[":superhero_medium_dark_skin_tone:"],category:"people"},":superhero_tone5:":{uc_base:"1f9b8-1f3ff",uc_full:"1f9b8-1f3ff",shortnames:[":superhero_dark_skin_tone:"],category:"people"},":supervillain_tone1:":{uc_base:"1f9b9-1f3fb",uc_full:"1f9b9-1f3fb",shortnames:[":supervillain_light_skin_tone:"],category:"people"},":supervillain_tone2:":{uc_base:"1f9b9-1f3fc",uc_full:"1f9b9-1f3fc",shortnames:[":supervillain_medium_light_skin_tone:"],category:"people"},":supervillainLine truncated
|
||||
category:"travel"},":amphora:":{uc_base:"1f3fa",uc_full:"1f3fa",shortnames:[],category:"objects"},":anatomical_heart:":{uc_base:"1fac0",uc_full:"1fac0",shortnames:[],category:"people"},":angel:":{uc_base:"1f47c",uc_full:"1f47c",shortnames:[],category:"people"},":anger:":{uc_base:"1f4a2",uc_full:"1f4a2",shortnames:[],category:"symbols"},":anger_right:":{uc_base:"1f5ef",uc_full:"1f5ef-fe0f",shortnames:[":right_anger_bubble:"],category:"symbols"},":angry:":{uc_base:"1f620",uc_full:"1f620",shortnames:[],category:"people"},":anguished:":{uc_base:"1f627",uc_full:"1f627",shortnames:[],category:"people"},":ant:":{uc_base:"1f41c",uc_full:"1f41c",shortnames:[],category:"nature"},":apple:":{uc_base:"1f34e",uc_full:"1f34e",shortnames:[],category:"food"},":arrow_down_small:":{uc_base:"1f53d",uc_full:"1f53d",shortnames:[],category:"symbols"},":arrow_up_small:":{uc_base:"1f53c",uc_full:"1f53c",shortnames:[],category:"symbols"},":arrows_clockwise:":{uc_base:"1f503",uc_full:"1f503",shortnames:[],category:"symbols"},":arrows_counterclockwise:":{uc_base:"1f504",uc_full:"1f504",shortnames:[],category:"symbols"},":art:":{uc_base:"1f3a8",uc_full:"1f3a8",shortnames:[],category:"activity"},":articulated_lorry:":{uc_base:"1f69b",uc_full:"1f69b",shortnames:[],category:"travel"},":astonished:":{uc_base:"1f632",uc_full:"1f632",shortnames:[],category:"people"},":athletic_shoe:":{uc_base:"1f45f",uc_full:"1f45f",shortnames:[],category:"people"},":atm:":{uc_base:"1f3e7",uc_full:"1f3e7",shortnames:[],category:"symbols"},":auto_rickshaw:":{uc_base:"1f6fa",uc_full:"1f6fa",shortnames:[],category:"travel"},":avocado:":{uc_base:"1f951",uc_full:"1f951",shortnames:[],category:"food"},":axe:":{uc_base:"1fa93",uc_full:"1fa93",shortnames:[],category:"objects"},":b:":{uc_base:"1f171",uc_full:"1f171-fe0f",shortnames:[],category:"symbols"},":baby:":{uc_base:"1f476",uc_full:"1f476",shortnames:[],category:"people"},":baby_bottle:":{uc_base:"1f37c",uc_full:"1f37c",shortnames:[],category:"food"},":baby_chick:":{uc_base:"1f424",uc_full:"1f424",shortnames:[],category:"nature"},":baby_symbol:":{uc_base:"1f6bc",uc_full:"1f6bc",shortnames:[],category:"symbols"},":back:":{uc_base:"1f519",uc_full:"1f519",shortnames:[],category:"symbols"},":bacon:":{uc_base:"1f953",uc_full:"1f953",shortnames:[],category:"food"},":badger:":{uc_base:"1f9a1",uc_full:"1f9a1",shortnames:[],category:"nature"},":badminton:":{uc_base:"1f3f8",uc_full:"1f3f8",shortnames:[],category:"activity"},":bagel:":{uc_base:"1f96f",uc_full:"1f96f",shortnames:[],category:"food"},":baggage_claim:":{uc_base:"1f6c4",uc_full:"1f6c4",shortnames:[],category:"symbols"},":bald:":{uc_base:"1f9b2",uc_full:"1f9b2",shortnames:[],category:"people"},":ballet_shoes:":{uc_base:"1fa70",uc_full:"1fa70",shortnames:[],category:"activity"},":balloon:":{uc_base:"1f388",uc_full:"1f388",shortnames:[],category:"objects"},":ballot_box:":{uc_base:"1f5f3",uc_full:"1f5f3-fe0f",shortnames:[":ballot_box_with_ballot:"],category:"objects"},":bamboo:":{uc_base:"1f38d",uc_full:"1f38d",shortnames:[],category:"nature"},":banana:":{uc_base:"1f34c",uc_full:"1f34c",shortnames:[],category:"food"},":banjo:":{uc_base:"1fa95",uc_full:"1fa95",shortnames:[],category:"activity"},":bank:":{uc_base:"1f3e6",uc_full:"1f3e6",shortnames:[],category:"travel"},":bar_chart:":{uc_base:"1f4ca",uc_full:"1f4ca",shortnames:[],category:"objects"},":barber:":{uc_base:"1f488",uc_full:"1f488",shortnames:[],category:"objects"},":basket:":{uc_base:"1f9fa",uc_full:"1f9fa",shortnames:[],category:"objects"},":basketball:":{uc_base:"1f3c0",uc_full:"1f3c0",shortnames:[],category:"activity"},":bat:":{uc_base:"1f987",uc_full:"1f987",shortnames:[],category:"nature"},":bath:":{uc_base:"1f6c0",uc_full:"1f6c0",shortnames:[],category:"objects"},":bathtub:":{uc_base:"1f6c1",uc_full:"1f6c1",shortnames:[],category:"objects"},":battery:":{uc_base:"1f50b",uc_full:"1f50b",shortnames:[],category:"objects"},":beach:":{uc_base:"1f3d6",uc_full:"1f3d6-fe0f",shortnames:[":beach_with_umbrella:"],category:"travel"},":bear:":{uc_base:"1f43b",uc_full:"1f43b",shortnames:[],category:"nature"},":bearded_person:":{uc_base:"1f9d4",uc_full:"1f9d4",shortnames:[],category:"people"},":beaver:":{uc_base:"1f9ab",uc_full:"1f9ab",shortnames:[],category:"nature"},":bed:":{uc_base:"1f6cf",uc_full:"1f6cf-fe0f",shortnames:[],category:"objects"},":bee:":{uc_base:"1f41d",uc_full:"1f41d",shortnames:[],category:"nature"},":beer:":{uc_base:"1f37a",uc_full:"1f37a",shortnames:[],category:"food"},":beers:":{uc_base:"1f37b",uc_full:"1f37b",shortnames:[],category:"food"},":beetle:":{uc_base:"1fab2",uc_full:"1fab2",shortnames:[],category:"nature"},":beginner:":{uc_base:"1f530",uc_full:"1f530",shortnames:[],category:"symbols"},":bell:":{uc_base:"1f514",uc_full:"1f514",shortnames:[],category:"symbols"},":bell_pepper:":{uc_base:"1fad1",uc_full:"1fad1",shortnames:[],category:"food"},":bellhop:":{uc_base:"1f6ce",uc_full:"1f6ce-fe0f",shortnames:[":bellhop_bell:"],category:"objects"},":bento:":{uc_base:"1f371",uc_full:"1f371",shoLine truncated
|
||||
},":fox:":{uc_base:"1f98a",uc_full:"1f98a",shortnames:[":fox_face:"],category:"nature"},":frame_photo:":{uc_base:"1f5bc",uc_full:"1f5bc-fe0f",shortnames:[":frame_with_picture:"],category:"objects"},":free:":{uc_base:"1f193",uc_full:"1f193",shortnames:[],category:"symbols"},":french_bread:":{uc_base:"1f956",uc_full:"1f956",shortnames:[":baguette_bread:"],category:"food"},":fried_shrimp:":{uc_base:"1f364",uc_full:"1f364",shortnames:[],category:"food"},":fries:":{uc_base:"1f35f",uc_full:"1f35f",shortnames:[],category:"food"},":frog:":{uc_base:"1f438",uc_full:"1f438",shortnames:[],category:"nature"},":frowning:":{uc_base:"1f626",uc_full:"1f626",shortnames:[],category:"people"},":full_moon:":{uc_base:"1f315",uc_full:"1f315",shortnames:[],category:"nature"},":full_moon_with_face:":{uc_base:"1f31d",uc_full:"1f31d",shortnames:[],category:"nature"},":game_die:":{uc_base:"1f3b2",uc_full:"1f3b2",shortnames:[],category:"activity"},":garlic:":{uc_base:"1f9c4",uc_full:"1f9c4",shortnames:[],category:"food"},":gem:":{uc_base:"1f48e",uc_full:"1f48e",shortnames:[],category:"objects"},":genie:":{uc_base:"1f9de",uc_full:"1f9de",shortnames:[],category:"people"},":ghost:":{uc_base:"1f47b",uc_full:"1f47b",shortnames:[],category:"people"},":gift:":{uc_base:"1f381",uc_full:"1f381",shortnames:[],category:"objects"},":gift_heart:":{uc_base:"1f49d",uc_full:"1f49d",shortnames:[],category:"symbols"},":giraffe:":{uc_base:"1f992",uc_full:"1f992",shortnames:[],category:"nature"},":girl:":{uc_base:"1f467",uc_full:"1f467",shortnames:[],category:"people"},":globe_with_meridians:":{uc_base:"1f310",uc_full:"1f310",shortnames:[],category:"symbols"},":gloves:":{uc_base:"1f9e4",uc_full:"1f9e4",shortnames:[],category:"people"},":goal:":{uc_base:"1f945",uc_full:"1f945",shortnames:[":goal_net:"],category:"activity"},":goat:":{uc_base:"1f410",uc_full:"1f410",shortnames:[],category:"nature"},":goggles:":{uc_base:"1f97d",uc_full:"1f97d",shortnames:[],category:"people"},":gorilla:":{uc_base:"1f98d",uc_full:"1f98d",shortnames:[],category:"nature"},":grapes:":{uc_base:"1f347",uc_full:"1f347",shortnames:[],category:"food"},":green_apple:":{uc_base:"1f34f",uc_full:"1f34f",shortnames:[],category:"food"},":green_book:":{uc_base:"1f4d7",uc_full:"1f4d7",shortnames:[],category:"objects"},":green_circle:":{uc_base:"1f7e2",uc_full:"1f7e2",shortnames:[],category:"symbols"},":green_heart:":{uc_base:"1f49a",uc_full:"1f49a",shortnames:[],category:"symbols"},":green_square:":{uc_base:"1f7e9",uc_full:"1f7e9",shortnames:[],category:"symbols"},":grimacing:":{uc_base:"1f62c",uc_full:"1f62c",shortnames:[],category:"people"},":grin:":{uc_base:"1f601",uc_full:"1f601",shortnames:[],category:"people"},":grinning:":{uc_base:"1f600",uc_full:"1f600",shortnames:[],category:"people"},":guard:":{uc_base:"1f482",uc_full:"1f482",shortnames:[":guardsman:"],category:"people"},":guide_dog:":{uc_base:"1f9ae",uc_full:"1f9ae",shortnames:[],category:"nature"},":guitar:":{uc_base:"1f3b8",uc_full:"1f3b8",shortnames:[],category:"activity"},":gun:":{uc_base:"1f52b",uc_full:"1f52b",shortnames:[],category:"objects"},":hamburger:":{uc_base:"1f354",uc_full:"1f354",shortnames:[],category:"food"},":hammer:":{uc_base:"1f528",uc_full:"1f528",shortnames:[],category:"objects"},":hamster:":{uc_base:"1f439",uc_full:"1f439",shortnames:[],category:"nature"},":hand_splayed:":{uc_base:"1f590",uc_full:"1f590",shortnames:[":raised_hand_with_fingers_splayed:"],category:"people"},":handbag:":{uc_base:"1f45c",uc_full:"1f45c",shortnames:[],category:"people"},":handshake:":{uc_base:"1f91d",uc_full:"1f91d",shortnames:[":shaking_hands:"],category:"people"},":hatched_chick:":{uc_base:"1f425",uc_full:"1f425",shortnames:[],category:"nature"},":hatching_chick:":{uc_base:"1f423",uc_full:"1f423",shortnames:[],category:"nature"},":head_bandage:":{uc_base:"1f915",uc_full:"1f915",shortnames:[":face_with_head_bandage:"],category:"people"},":headphones:":{uc_base:"1f3a7",uc_full:"1f3a7",shortnames:[],category:"activity"},":headstone:":{uc_base:"1faa6",uc_full:"1faa6",shortnames:[],category:"objects"},":hear_no_evil:":{uc_base:"1f649",uc_full:"1f649",shortnames:[],category:"nature"},":heart_decoration:":{uc_base:"1f49f",uc_full:"1f49f",shortnames:[],category:"symbols"},":heart_eyes:":{uc_base:"1f60d",uc_full:"1f60d",shortnames:[],category:"people"},":heart_eyes_cat:":{uc_base:"1f63b",uc_full:"1f63b",shortnames:[],category:"people"},":heartbeat:":{uc_base:"1f493",uc_full:"1f493",shortnames:[],category:"symbols"},":heartpulse:":{uc_base:"1f497",uc_full:"1f497",shortnames:[],category:"symbols"},":heavy_dollar_sign:":{uc_base:"1f4b2",uc_full:"1f4b2",shortnames:[],category:"symbols"},":hedgehog:":{uc_base:"1f994",uc_full:"1f994",shortnames:[],category:"nature"},":helicopter:":{uc_base:"1f681",uc_full:"1f681",shortnames:[],category:"travel"},":herb:":{uc_base:"1f33f",uc_full:"1f33f",shortnames:[],category:"nature"},":hibiscus:":{uc_base:"1f33a",uc_full:"1f33a",shortnames:[],category:"nature"},":high_brightness:":{uc_base:"1f506",uc_full:"Line truncated
|
||||
uc_base:"1f43d",uc_full:"1f43d",shortnames:[],category:"nature"},":pill:":{uc_base:"1f48a",uc_full:"1f48a",shortnames:[],category:"objects"},":pinched_fingers:":{uc_base:"1f90c",uc_full:"1f90c",shortnames:[],category:"people"},":pinching_hand:":{uc_base:"1f90f",uc_full:"1f90f",shortnames:[],category:"people"},":pineapple:":{uc_base:"1f34d",uc_full:"1f34d",shortnames:[],category:"food"},":ping_pong:":{uc_base:"1f3d3",uc_full:"1f3d3",shortnames:[":table_tennis:"],category:"activity"},":pizza:":{uc_base:"1f355",uc_full:"1f355",shortnames:[],category:"food"},":pi\xf1ata:":{uc_base:"1fa85",uc_full:"1fa85",shortnames:[],category:"objects"},":placard:":{uc_base:"1faa7",uc_full:"1faa7",shortnames:[],category:"objects"},":place_of_worship:":{uc_base:"1f6d0",uc_full:"1f6d0",shortnames:[":worship_symbol:"],category:"symbols"},":pleading_face:":{uc_base:"1f97a",uc_full:"1f97a",shortnames:[],category:"people"},":plunger:":{uc_base:"1faa0",uc_full:"1faa0",shortnames:[],category:"objects"},":point_down:":{uc_base:"1f447",uc_full:"1f447",shortnames:[],category:"people"},":point_left:":{uc_base:"1f448",uc_full:"1f448",shortnames:[],category:"people"},":point_right:":{uc_base:"1f449",uc_full:"1f449",shortnames:[],category:"people"},":point_up_2:":{uc_base:"1f446",uc_full:"1f446",shortnames:[],category:"people"},":police_car:":{uc_base:"1f693",uc_full:"1f693",shortnames:[],category:"travel"},":police_officer:":{uc_base:"1f46e",uc_full:"1f46e",shortnames:[":cop:"],category:"people"},":poodle:":{uc_base:"1f429",uc_full:"1f429",shortnames:[],category:"nature"},":poop:":{uc_base:"1f4a9",uc_full:"1f4a9",shortnames:[":shit:",":hankey:",":poo:"],category:"people"},":popcorn:":{uc_base:"1f37f",uc_full:"1f37f",shortnames:[],category:"food"},":post_office:":{uc_base:"1f3e3",uc_full:"1f3e3",shortnames:[],category:"travel"},":postal_horn:":{uc_base:"1f4ef",uc_full:"1f4ef",shortnames:[],category:"objects"},":postbox:":{uc_base:"1f4ee",uc_full:"1f4ee",shortnames:[],category:"objects"},":potable_water:":{uc_base:"1f6b0",uc_full:"1f6b0",shortnames:[],category:"objects"},":potato:":{uc_base:"1f954",uc_full:"1f954",shortnames:[],category:"food"},":potted_plant:":{uc_base:"1fab4",uc_full:"1fab4",shortnames:[],category:"nature"},":pouch:":{uc_base:"1f45d",uc_full:"1f45d",shortnames:[],category:"people"},":poultry_leg:":{uc_base:"1f357",uc_full:"1f357",shortnames:[],category:"food"},":pound:":{uc_base:"1f4b7",uc_full:"1f4b7",shortnames:[],category:"objects"},":pouting_cat:":{uc_base:"1f63e",uc_full:"1f63e",shortnames:[],category:"people"},":pray:":{uc_base:"1f64f",uc_full:"1f64f",shortnames:[],category:"people"},":prayer_beads:":{uc_base:"1f4ff",uc_full:"1f4ff",shortnames:[],category:"objects"},":pregnant_woman:":{uc_base:"1f930",uc_full:"1f930",shortnames:[":expecting_woman:"],category:"people"},":pretzel:":{uc_base:"1f968",uc_full:"1f968",shortnames:[],category:"food"},":prince:":{uc_base:"1f934",uc_full:"1f934",shortnames:[],category:"people"},":princess:":{uc_base:"1f478",uc_full:"1f478",shortnames:[],category:"people"},":printer:":{uc_base:"1f5a8",uc_full:"1f5a8-fe0f",shortnames:[],category:"objects"},":probing_cane:":{uc_base:"1f9af",uc_full:"1f9af",shortnames:[],category:"travel"},":projector:":{uc_base:"1f4fd",uc_full:"1f4fd-fe0f",shortnames:[":film_projector:"],category:"objects"},":punch:":{uc_base:"1f44a",uc_full:"1f44a",shortnames:[],category:"people"},":purple_circle:":{uc_base:"1f7e3",uc_full:"1f7e3",shortnames:[],category:"symbols"},":purple_heart:":{uc_base:"1f49c",uc_full:"1f49c",shortnames:[],category:"symbols"},":purple_square:":{uc_base:"1f7ea",uc_full:"1f7ea",shortnames:[],category:"symbols"},":purse:":{uc_base:"1f45b",uc_full:"1f45b",shortnames:[],category:"people"},":pushpin:":{uc_base:"1f4cc",uc_full:"1f4cc",shortnames:[],category:"objects"},":put_litter_in_its_place:":{uc_base:"1f6ae",uc_full:"1f6ae",shortnames:[],category:"symbols"},":rabbit2:":{uc_base:"1f407",uc_full:"1f407",shortnames:[],category:"nature"},":rabbit:":{uc_base:"1f430",uc_full:"1f430",shortnames:[],category:"nature"},":raccoon:":{uc_base:"1f99d",uc_full:"1f99d",shortnames:[],category:"nature"},":race_car:":{uc_base:"1f3ce",uc_full:"1f3ce-fe0f",shortnames:[":racing_car:"],category:"travel"},":racehorse:":{uc_base:"1f40e",uc_full:"1f40e",shortnames:[],category:"nature"},":radio:":{uc_base:"1f4fb",uc_full:"1f4fb",shortnames:[],category:"objects"},":radio_button:":{uc_base:"1f518",uc_full:"1f518",shortnames:[],category:"symbols"},":rage:":{uc_base:"1f621",uc_full:"1f621",shortnames:[],category:"people"},":railway_car:":{uc_base:"1f683",uc_full:"1f683",shortnames:[],category:"travel"},":railway_track:":{uc_base:"1f6e4",uc_full:"1f6e4-fe0f",shortnames:[":railroad_track:"],category:"travel"},":rainbow:":{uc_base:"1f308",uc_full:"1f308",shortnames:[],category:"nature"},":raised_back_of_hand:":{uc_base:"1f91a",uc_full:"1f91a",shortnames:[":back_of_hand:"],category:"people"},":raised_hands:":{uc_base:"1f64c",uc_full:"1f64c",shortnames:[],category:"people"},":ramLine truncated
|
||||
category:"nature"},":woman:":{uc_base:"1f469",uc_full:"1f469",shortnames:[],category:"people"},":woman_with_headscarf:":{uc_base:"1f9d5",uc_full:"1f9d5",shortnames:[],category:"people"},":womans_clothes:":{uc_base:"1f45a",uc_full:"1f45a",shortnames:[],category:"people"},":womans_flat_shoe:":{uc_base:"1f97f",uc_full:"1f97f",shortnames:[],category:"people"},":womans_hat:":{uc_base:"1f452",uc_full:"1f452",shortnames:[],category:"people"},":womens:":{uc_base:"1f6ba",uc_full:"1f6ba",shortnames:[],category:"symbols"},":wood:":{uc_base:"1fab5",uc_full:"1fab5",shortnames:[],category:"nature"},":woozy_face:":{uc_base:"1f974",uc_full:"1f974",shortnames:[],category:"people"},":worm:":{uc_base:"1fab1",uc_full:"1fab1",shortnames:[],category:"nature"},":worried:":{uc_base:"1f61f",uc_full:"1f61f",shortnames:[],category:"people"},":wrench:":{uc_base:"1f527",uc_full:"1f527",shortnames:[],category:"objects"},":yarn:":{uc_base:"1f9f6",uc_full:"1f9f6",shortnames:[],category:"people"},":yawning_face:":{uc_base:"1f971",uc_full:"1f971",shortnames:[],category:"people"},":yellow_circle:":{uc_base:"1f7e1",uc_full:"1f7e1",shortnames:[],category:"symbols"},":yellow_heart:":{uc_base:"1f49b",uc_full:"1f49b",shortnames:[],category:"symbols"},":yellow_square:":{uc_base:"1f7e8",uc_full:"1f7e8",shortnames:[],category:"symbols"},":yen:":{uc_base:"1f4b4",uc_full:"1f4b4",shortnames:[],category:"objects"},":yo_yo:":{uc_base:"1fa80",uc_full:"1fa80",shortnames:[],category:"activity"},":yum:":{uc_base:"1f60b",uc_full:"1f60b",shortnames:[],category:"people"},":zany_face:":{uc_base:"1f92a",uc_full:"1f92a",shortnames:[],category:"people"},":zebra:":{uc_base:"1f993",uc_full:"1f993",shortnames:[],category:"nature"},":zipper_mouth:":{uc_base:"1f910",uc_full:"1f910",shortnames:[":zipper_mouth_face:"],category:"people"},":zombie:":{uc_base:"1f9df",uc_full:"1f9df",shortnames:[],category:"people"},":zzz:":{uc_base:"1f4a4",uc_full:"1f4a4",shortnames:[],category:"symbols"},":airplane:":{uc_base:"2708",uc_full:"2708-fe0f",shortnames:[],category:"travel"},":alarm_clock:":{uc_base:"23f0",uc_full:"23f0",shortnames:[],category:"objects"},":alembic:":{uc_base:"2697",uc_full:"2697-fe0f",shortnames:[],category:"objects"},":anchor:":{uc_base:"2693",uc_full:"2693",shortnames:[],category:"travel"},":aquarius:":{uc_base:"2652",uc_full:"2652",shortnames:[],category:"symbols"},":aries:":{uc_base:"2648",uc_full:"2648",shortnames:[],category:"symbols"},":arrow_backward:":{uc_base:"25c0",uc_full:"25c0-fe0f",shortnames:[],category:"symbols"},":arrow_double_down:":{uc_base:"23ec",uc_full:"23ec",shortnames:[],category:"symbols"},":arrow_double_up:":{uc_base:"23eb",uc_full:"23eb",shortnames:[],category:"symbols"},":arrow_down:":{uc_base:"2b07",uc_full:"2b07-fe0f",shortnames:[],category:"symbols"},":arrow_forward:":{uc_base:"25b6",uc_full:"25b6-fe0f",shortnames:[],category:"symbols"},":arrow_heading_down:":{uc_base:"2935",uc_full:"2935-fe0f",shortnames:[],category:"symbols"},":arrow_heading_up:":{uc_base:"2934",uc_full:"2934-fe0f",shortnames:[],category:"symbols"},":arrow_left:":{uc_base:"2b05",uc_full:"2b05-fe0f",shortnames:[],category:"symbols"},":arrow_lower_left:":{uc_base:"2199",uc_full:"2199-fe0f",shortnames:[],category:"symbols"},":arrow_lower_right:":{uc_base:"2198",uc_full:"2198-fe0f",shortnames:[],category:"symbols"},":arrow_right:":{uc_base:"27a1",uc_full:"27a1-fe0f",shortnames:[],category:"symbols"},":arrow_right_hook:":{uc_base:"21aa",uc_full:"21aa-fe0f",shortnames:[],category:"symbols"},":arrow_up:":{uc_base:"2b06",uc_full:"2b06-fe0f",shortnames:[],category:"symbols"},":arrow_up_down:":{uc_base:"2195",uc_full:"2195-fe0f",shortnames:[],category:"symbols"},":arrow_upper_left:":{uc_base:"2196",uc_full:"2196-fe0f",shortnames:[],category:"symbols"},":arrow_upper_right:":{uc_base:"2197",uc_full:"2197-fe0f",shortnames:[],category:"symbols"},":asterisk_symbol:":{uc_base:"002a",uc_full:"002a-fe0f",shortnames:[],category:"symbols"},":atom:":{uc_base:"269b",uc_full:"269b-fe0f",shortnames:[":atom_symbol:"],category:"symbols"},":ballot_box_with_check:":{uc_base:"2611",uc_full:"2611-fe0f",shortnames:[],category:"symbols"},":bangbang:":{uc_base:"203c",uc_full:"203c-fe0f",shortnames:[],category:"symbols"},":baseball:":{uc_base:"26be",uc_full:"26be",shortnames:[],category:"activity"},":beach_umbrella:":{uc_base:"26f1",uc_full:"26f1-fe0f",shortnames:[":umbrella_on_ground:"],category:"travel"},":biohazard:":{uc_base:"2623",uc_full:"2623-fe0f",shortnames:[":biohazard_sign:"],category:"symbols"},":black_circle:":{uc_base:"26ab",uc_full:"26ab",shortnames:[],category:"symbols"},":black_large_square:":{uc_base:"2b1b",uc_full:"2b1b",shortnames:[],category:"symbols"},":black_medium_small_square:":{uc_base:"25fe",uc_full:"25fe",shortnames:[],category:"symbols"},":black_medium_square:":{uc_base:"25fc",uc_full:"25fc-fe0f",shortnames:[],category:"symbols"},":black_nib:":{uc_base:"2712",uc_full:"2712-fe0f",shortnames:[],category:"objects"},":black_small_square:":{uc_base:"25aa",uc_full:"25aa-fe0fLine truncated
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Full screen for the trading-network graph.
|
||||
*
|
||||
* What goes full screen is the graph's own box: its header (caption, subtext,
|
||||
* spread and full-screen buttons), the canvas and the time-lapse panel - not
|
||||
* the page around it. Esc leaves it; that is the browser's own handling.
|
||||
*
|
||||
* In full screen the header floats over the canvas (see the markup), and it
|
||||
* and the playback panel fade out after IDLE_MS without activity, so the graph
|
||||
* has the whole screen. Any pointer movement, tap, drag, wheel or key brings
|
||||
* them back for another IDLE_MS.
|
||||
*/
|
||||
|
||||
const IDLE_MS = 2000;
|
||||
|
||||
/**
|
||||
* @param {object} opts
|
||||
* @param {HTMLElement} opts.screenBox The box that goes full screen.
|
||||
* @param {HTMLElement} opts.frameBox The graph's box; fills the screen (by CSS).
|
||||
* @param {HTMLElement} opts.headerBox The header row that hides.
|
||||
* @param {HTMLElement} opts.button The full-screen toggle.
|
||||
* @param {Function} opts.panel -> the playback panel, or null.
|
||||
* @param {AbortSignal} opts.signal Removes every listener on rebuild.
|
||||
* @param {Function} opts.onResize (inFullscreen, headerHeight) after a switch.
|
||||
*/
|
||||
function setupFullscreen({ screenBox, frameBox, headerBox, button, panel, signal, onResize }) {
|
||||
if (!screenBox || !frameBox || !button) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Prefixed for older Safari; absent altogether on iPhone, where only video
|
||||
// can go full screen - the button then goes rather than doing nothing.
|
||||
const enter = screenBox.requestFullscreen || screenBox.webkitRequestFullscreen;
|
||||
const leave = document.exitFullscreen || document.webkitExitFullscreen;
|
||||
|
||||
if (!enter || !leave) {
|
||||
button.hidden = true;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const active = () => document.fullscreenElement || document.webkitFullscreenElement || null;
|
||||
const options = { capture: true, passive: true, signal };
|
||||
|
||||
let idleTimer = null;
|
||||
// What the pointer is doing, for busy().
|
||||
let pointerType = 'mouse';
|
||||
let pressed = false;
|
||||
let lastPoint = null;
|
||||
|
||||
// Whether the pointer's last known position lies on an element.
|
||||
//
|
||||
// By position rather than ":hover": hidden controls take no pointer
|
||||
// events, so a mouse that wakes them by moving onto them and then rests
|
||||
// there never makes them ":hover" - they hid from under it.
|
||||
const under = (el) => {
|
||||
if (!el || !lastPoint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const r = el.getBoundingClientRect();
|
||||
|
||||
return lastPoint.x >= r.left && lastPoint.x <= r.right
|
||||
&& lastPoint.y >= r.top && lastPoint.y <= r.bottom;
|
||||
};
|
||||
|
||||
const busy = () => {
|
||||
if (pressed) {
|
||||
// Mid-drag: a slider, or panning the graph.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Kept up while the mouse rests on them. Mouse only: a finger lifted
|
||||
// after a tap is not resting anywhere, and the last tap would
|
||||
// otherwise keep them up for ever.
|
||||
//
|
||||
// Focus deliberately does NOT count. Clicking the full-screen button
|
||||
// leaves it focused inside the header, and treating focus as "in use"
|
||||
// meant the controls never hid at all.
|
||||
return pointerType === 'mouse'
|
||||
&& [headerBox, panel ? panel() : null].some(under);
|
||||
};
|
||||
|
||||
const scheduleHide = () => {
|
||||
clearTimeout(idleTimer);
|
||||
idleTimer = setTimeout(() => {
|
||||
if (active() !== screenBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (busy()) {
|
||||
scheduleHide();
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
screenBox.classList.add('controls-hidden');
|
||||
}, IDLE_MS);
|
||||
};
|
||||
|
||||
const wake = () => {
|
||||
if (active() !== screenBox) {
|
||||
return;
|
||||
}
|
||||
|
||||
screenBox.classList.remove('controls-hidden');
|
||||
scheduleHide();
|
||||
};
|
||||
|
||||
// Capture phase on the document: the canvas handles pointer events
|
||||
// itself, and a drag on it must still count as movement. On the document
|
||||
// rather than the screen box so that nothing inside the box - whatever
|
||||
// element ends up under the pointer after a rebuild - can keep the
|
||||
// controls from coming back; wake() itself ignores anything while the box
|
||||
// is not full screen. mousemove as well, for browsers that leave out
|
||||
// pointer events in some cases.
|
||||
['pointermove', 'mousemove', 'pointerdown', 'touchstart', 'wheel', 'keydown'].forEach((type) => {
|
||||
document.addEventListener(type, wake, options);
|
||||
});
|
||||
|
||||
// A rebuild aborts these listeners and sets up afresh. A hide still
|
||||
// pending from the old setup would otherwise fire later and hide the
|
||||
// controls behind the new one's back.
|
||||
if (signal) {
|
||||
signal.addEventListener('abort', () => clearTimeout(idleTimer), { once: true });
|
||||
}
|
||||
|
||||
screenBox.addEventListener('pointerdown', (event) => {
|
||||
pressed = true;
|
||||
pointerType = event.pointerType || 'mouse';
|
||||
lastPoint = { x: event.clientX, y: event.clientY };
|
||||
}, options);
|
||||
|
||||
screenBox.addEventListener('pointermove', (event) => {
|
||||
pointerType = event.pointerType || pointerType;
|
||||
lastPoint = { x: event.clientX, y: event.clientY };
|
||||
}, options);
|
||||
|
||||
// Gone from the screen box altogether (another window, the taskbar).
|
||||
screenBox.addEventListener('pointerleave', () => {
|
||||
lastPoint = null;
|
||||
}, { signal });
|
||||
|
||||
// On the document: a drag can end outside the box. Waking again on
|
||||
// release restarts the countdown from the end of the drag, not its start.
|
||||
['pointerup', 'pointercancel'].forEach((type) => {
|
||||
document.addEventListener(type, () => {
|
||||
if (pressed) {
|
||||
pressed = false;
|
||||
wake();
|
||||
}
|
||||
}, options);
|
||||
});
|
||||
|
||||
button.addEventListener('click', (event) => {
|
||||
event.preventDefault();
|
||||
|
||||
const result = active() === screenBox
|
||||
? leave.call(document)
|
||||
: enter.call(screenBox);
|
||||
|
||||
if (result && typeof result.catch === 'function') {
|
||||
result.catch(() => {});
|
||||
}
|
||||
}, { signal });
|
||||
|
||||
const onChange = () => {
|
||||
const on = active() === screenBox;
|
||||
const label = on ? button.dataset.labelExit : button.dataset.labelEnter;
|
||||
|
||||
button.setAttribute('title', label);
|
||||
button.setAttribute('aria-label', label);
|
||||
button.querySelector('[data-fullscreen-enter]')?.classList.toggle('hidden', on);
|
||||
button.querySelector('[data-fullscreen-exit]')?.classList.toggle('hidden', !on);
|
||||
|
||||
// The layout itself - the header floating over the canvas, the graph
|
||||
// taking the whole screen - is pure CSS on :fullscreen (the
|
||||
// group-fullscreen variant in the markup). Not a class or inline
|
||||
// height set here: the time-lapse settings are wire:model.live, and
|
||||
// the morph after each one reset both to the server's markup while
|
||||
// the box stayed full screen, leaving the graph at its normal height.
|
||||
screenBox.classList.remove('controls-hidden');
|
||||
clearTimeout(idleTimer);
|
||||
|
||||
if (on) {
|
||||
scheduleHide();
|
||||
}
|
||||
|
||||
if (onResize) {
|
||||
requestAnimationFrame(() => onResize(on, headerBox ? headerBox.offsetHeight : 0));
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('fullscreenchange', onChange, { signal });
|
||||
document.addEventListener('webkitfullscreenchange', onChange, { signal });
|
||||
}
|
||||
|
||||
export { setupFullscreen, IDLE_MS };
|
||||
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* The trading-network layout, as a pure function of nodes and edges.
|
||||
*
|
||||
* Kept apart from the page so it can run in a Web Worker (see
|
||||
* network-layout-worker.js). fcose runs synchronously: on the ~1200-profile
|
||||
* production graph it blocks ~4.5s on a desktop and several times that on a
|
||||
* phone, where iOS stops a script that runs too long in one go - the graph
|
||||
* then stayed on "Arranging N profiles..." for good. In a worker the page's
|
||||
* own thread never blocks, whatever the device.
|
||||
*
|
||||
* Profiled on the real graph: ~70% of the time is fcose's repulsion and
|
||||
* overlap maths, and numIter makes no difference (250 or 2500, same time,
|
||||
* same quality) - so moving it off the main thread is the fix, not tuning.
|
||||
*/
|
||||
|
||||
/**
|
||||
* @param {Function} cytoscape The cytoscape factory, with fcose registered.
|
||||
* @param {object} input {nodes: [{id, size}], edges: [{id, source, target}], options}
|
||||
* @returns {Promise<Object<string, {x: number, y: number}>>} positions by node id
|
||||
*/
|
||||
export function layoutPositions(cytoscape, { nodes, edges, options }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
let cy = null;
|
||||
|
||||
try {
|
||||
cy = cytoscape({
|
||||
headless: true,
|
||||
// Needed headless for node sizes to count: fcose keeps nodes
|
||||
// apart by their real dimensions, so a layout computed on
|
||||
// default-sized nodes would not match the drawing.
|
||||
styleEnabled: true,
|
||||
style: [{ selector: 'node', style: { width: 'data(size)', height: 'data(size)' } }],
|
||||
elements: [
|
||||
...nodes.map((n) => ({ data: { id: n.id, size: n.size || 8 } })),
|
||||
...edges.map((e) => ({ data: { id: e.id, source: e.source, target: e.target } })),
|
||||
],
|
||||
});
|
||||
|
||||
const layout = cy.layout({ ...options, animate: false });
|
||||
|
||||
layout.one('layoutstop', () => {
|
||||
const positions = {};
|
||||
|
||||
cy.nodes().forEach((node) => {
|
||||
const p = node.position();
|
||||
|
||||
positions[node.id()] = { x: p.x, y: p.y };
|
||||
});
|
||||
|
||||
cy.destroy();
|
||||
resolve(positions);
|
||||
});
|
||||
|
||||
layout.run();
|
||||
} catch (error) {
|
||||
if (cy && !cy.destroyed()) {
|
||||
cy.destroy();
|
||||
}
|
||||
|
||||
reject(error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Web Worker running the network layout off the page's main thread.
|
||||
*
|
||||
* In production Vite bundles it into one self-contained classic worker, which
|
||||
* older iOS versions also run; under the dev server it runs as a module worker
|
||||
* instead, since the dev server serves it unbundled (see getLayoutWorker() in
|
||||
* stats-network.js). The work itself lives in network-layout-core.js.
|
||||
*/
|
||||
import cytoscape from 'cytoscape';
|
||||
import fcose from 'cytoscape-fcose';
|
||||
import { layoutPositions } from './network-layout-core';
|
||||
|
||||
cytoscape.use(fcose);
|
||||
|
||||
self.onmessage = (event) => {
|
||||
const { id, nodes, edges, options } = event.data || {};
|
||||
|
||||
layoutPositions(cytoscape, { nodes, edges, options })
|
||||
.then((positions) => self.postMessage({ id, positions }))
|
||||
.catch((error) => self.postMessage({ id, error: String((error && error.message) || error) }));
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,134 @@
|
||||
/*
|
||||
* Poll ledger verifier. Runs in the voter's browser (and in Node for the tests), so checking a
|
||||
* poll does not depend on the server's own word. It is inserted into the verification page as is:
|
||||
* this file is the code that runs.
|
||||
*
|
||||
* Format (App\Polls\Ledger, format version 1):
|
||||
* hash = SHA-256 of the canonical JSON of
|
||||
* {v, poll_id, sequence, entry_type, fingerprint, refers_to_sequence, payload, recorded_at, previous_hash}
|
||||
* Canonical JSON: object keys sorted by their UTF-8 bytes, lists in order, no whitespace,
|
||||
* unicode and slashes unescaped. The first entry's previous_hash is 64 zeros.
|
||||
* Ballots that count: "cast" / "change" entries no later entry refers to (via refers_to_sequence).
|
||||
* Tally: per question, how often each distinct value was given, as a list of
|
||||
* {question_id, value (canonical JSON), count} sorted by question_id, then value bytes.
|
||||
*/
|
||||
(function (root) {
|
||||
'use strict';
|
||||
|
||||
const GENESIS = '0'.repeat(64);
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function byteCompare(a, b) {
|
||||
const x = encoder.encode(a);
|
||||
const y = encoder.encode(b);
|
||||
const length = Math.min(x.length, y.length);
|
||||
for (let i = 0; i < length; i++) {
|
||||
if (x[i] !== y[i]) {
|
||||
return x[i] - y[i];
|
||||
}
|
||||
}
|
||||
return x.length - y.length;
|
||||
}
|
||||
|
||||
function canonical(value) {
|
||||
if (value === null || typeof value !== 'object') {
|
||||
return JSON.stringify(value);
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return '[' + value.map(canonical).join(',') + ']';
|
||||
}
|
||||
return '{' + Object.keys(value).sort(byteCompare)
|
||||
.map((key) => JSON.stringify(key) + ':' + canonical(value[key]))
|
||||
.join(',') + '}';
|
||||
}
|
||||
|
||||
async function sha256(text) {
|
||||
const digest = await root.crypto.subtle.digest('SHA-256', encoder.encode(text));
|
||||
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||
function entryHashInput(entry) {
|
||||
return {
|
||||
v: entry.v,
|
||||
poll_id: entry.poll_id,
|
||||
sequence: entry.sequence,
|
||||
entry_type: entry.entry_type,
|
||||
fingerprint: entry.fingerprint,
|
||||
refers_to_sequence: entry.refers_to_sequence,
|
||||
payload: entry.payload,
|
||||
recorded_at: entry.recorded_at,
|
||||
previous_hash: entry.previous_hash,
|
||||
};
|
||||
}
|
||||
|
||||
function activeBallots(entries) {
|
||||
const replaced = new Set(entries.filter((e) => e.refers_to_sequence !== null).map((e) => e.refers_to_sequence));
|
||||
return entries.filter((e) => (e.entry_type === 'cast' || e.entry_type === 'change') && !replaced.has(e.sequence));
|
||||
}
|
||||
|
||||
function tally(ballots) {
|
||||
const counts = new Map();
|
||||
for (const ballot of ballots) {
|
||||
for (const answer of (ballot.payload && ballot.payload.answers) || []) {
|
||||
if (answer.value === null || answer.value === undefined) {
|
||||
continue;
|
||||
}
|
||||
const key = answer.question_id + '\u0000' + canonical(answer.value);
|
||||
counts.set(key, (counts.get(key) || 0) + 1);
|
||||
}
|
||||
}
|
||||
return Array.from(counts, ([key, count]) => {
|
||||
const [questionId, value] = key.split('\u0000');
|
||||
return { question_id: Number(questionId), value: value, count: count };
|
||||
}).sort((a, b) => (a.question_id - b.question_id) || byteCompare(a.value, b.value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Check a ledger as published by the polls.ledger route.
|
||||
* @returns {Promise<{valid: boolean, checks: Array<{ok: boolean, key: string, detail: string}>}>}
|
||||
*/
|
||||
async function verify(data) {
|
||||
const checks = [];
|
||||
const check = (ok, key, detail) => checks.push({ ok: ok, key: key, detail: detail || '' });
|
||||
const entries = data.entries || [];
|
||||
|
||||
// 1. The hash chain
|
||||
let previous = GENESIS;
|
||||
let chainOk = true;
|
||||
for (let i = 0; i < entries.length; i++) {
|
||||
const entry = entries[i];
|
||||
const hash = await sha256(canonical(entryHashInput(entry)));
|
||||
if (entry.sequence !== i + 1 || entry.previous_hash !== previous || hash !== entry.hash) {
|
||||
chainOk = false;
|
||||
check(false, 'chain', 'broken at entry ' + entry.sequence);
|
||||
break;
|
||||
}
|
||||
previous = entry.hash;
|
||||
}
|
||||
if (chainOk) {
|
||||
check(true, 'chain', entries.length + ' entries, head ' + previous.slice(0, 16));
|
||||
}
|
||||
|
||||
// 2. The recount against the published result
|
||||
const recount = tally(activeBallots(entries));
|
||||
check(canonical(recount) === canonical(data.published_tally || []), 'recount',
|
||||
activeBallots(entries).length + ' ballots counted');
|
||||
|
||||
// 3. The seal, if the poll was closed
|
||||
const sealEntry = entries.find((e) => e.entry_type === 'seal');
|
||||
if (data.seal_hash || sealEntry) {
|
||||
let sealOk = !!sealEntry && sealEntry.hash === data.seal_hash;
|
||||
if (sealOk) {
|
||||
const before = entries.filter((e) => e.sequence < sealEntry.sequence);
|
||||
const sealedBallots = activeBallots(before);
|
||||
sealOk = sealEntry.payload.active_ballots === sealedBallots.length
|
||||
&& sealEntry.payload.tally_sha256 === await sha256(canonical(tally(sealedBallots)));
|
||||
}
|
||||
check(sealOk, 'seal', data.seal_hash ? data.seal_hash.slice(0, 16) : '');
|
||||
}
|
||||
|
||||
return { valid: checks.every((c) => c.ok), checks: checks };
|
||||
}
|
||||
|
||||
root.PollLedgerVerifier = { verify: verify, canonical: canonical, sha256: sha256, tally: tally };
|
||||
})(typeof window !== 'undefined' ? window : globalThis);
|
||||
@@ -0,0 +1,200 @@
|
||||
// Enhanced presence tracker with AJAX fallback
|
||||
class PresenceTracker {
|
||||
constructor() {
|
||||
this.heartbeatInterval = null;
|
||||
this.offlineTimeout = null;
|
||||
this.lastActivity = Date.now();
|
||||
this.isOnline = true;
|
||||
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.setupActivityTracking();
|
||||
this.setupVisibilityHandling();
|
||||
this.setupBeforeUnload();
|
||||
this.startHeartbeat();
|
||||
}
|
||||
|
||||
setupActivityTracking() {
|
||||
const events = ['mousedown', 'mousemove', 'keypress', 'scroll', 'touchstart'];
|
||||
|
||||
events.forEach(event => {
|
||||
document.addEventListener(event, () => {
|
||||
this.updateActivity();
|
||||
}, { passive: true });
|
||||
});
|
||||
}
|
||||
|
||||
setupVisibilityHandling() {
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.hidden) {
|
||||
this.handleUserAway();
|
||||
} else {
|
||||
this.handleUserBack();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setupBeforeUnload() {
|
||||
window.addEventListener('beforeunload', () => {
|
||||
this.setOffline();
|
||||
});
|
||||
|
||||
window.addEventListener('pagehide', () => {
|
||||
this.setOffline();
|
||||
});
|
||||
}
|
||||
|
||||
updateActivity() {
|
||||
const now = Date.now();
|
||||
|
||||
if (now - this.lastActivity > 15000) { // 15 seconds
|
||||
this.lastActivity = now;
|
||||
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
|
||||
// Set offline timeout for 5 minutes of inactivity
|
||||
this.offlineTimeout = setTimeout(() => {
|
||||
this.setOffline();
|
||||
}, 300000);
|
||||
|
||||
// Update Livewire components
|
||||
this.notifyLivewireComponents('handleUserActivity');
|
||||
}
|
||||
}
|
||||
|
||||
handleUserAway() {
|
||||
// Set shorter offline timeout when tab is hidden
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
|
||||
this.offlineTimeout = setTimeout(() => {
|
||||
this.setOffline();
|
||||
}, 30000); // 30 seconds when tab is hidden
|
||||
}
|
||||
|
||||
handleUserBack() {
|
||||
this.lastActivity = Date.now();
|
||||
this.heartbeat(); // Immediate heartbeat when coming back
|
||||
|
||||
// Reset normal offline timeout
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
|
||||
this.offlineTimeout = setTimeout(() => {
|
||||
this.setOffline();
|
||||
}, 300000); // 5 minutes
|
||||
}
|
||||
|
||||
startHeartbeat() {
|
||||
// Send heartbeat every 30 seconds
|
||||
this.heartbeatInterval = setInterval(() => {
|
||||
if (!document.hidden && this.isOnline) {
|
||||
this.heartbeat();
|
||||
}
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
heartbeat() {
|
||||
const guards = this.getActiveGuards();
|
||||
|
||||
guards.forEach(guard => {
|
||||
fetch('/presence/heartbeat', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
body: JSON.stringify({ guard: guard })
|
||||
}).then(response => {
|
||||
if (response.ok) {
|
||||
this.isOnline = true;
|
||||
this.notifyLivewireComponents('handleUserActivity');
|
||||
}
|
||||
}).catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
setOffline() {
|
||||
if (!this.isOnline) return; // Already offline
|
||||
|
||||
const guards = this.getActiveGuards();
|
||||
|
||||
guards.forEach(guard => {
|
||||
// Use sendBeacon for reliability during page unload
|
||||
const data = JSON.stringify({ guard: guard });
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
navigator.sendBeacon('/presence/offline', data);
|
||||
} else {
|
||||
fetch('/presence/offline', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.content
|
||||
},
|
||||
body: data
|
||||
}).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
this.isOnline = false;
|
||||
this.notifyLivewireComponents('handleUserOffline');
|
||||
}
|
||||
|
||||
getActiveGuards() {
|
||||
const guards = new Set();
|
||||
document.querySelectorAll('.user-presence-container').forEach(container => {
|
||||
const guard = container.getAttribute('data-guard') || 'web';
|
||||
guards.add(guard);
|
||||
});
|
||||
// Fall back to the active guard stored on the body element by the layout
|
||||
if (guards.size === 0) {
|
||||
const bodyGuard = document.body.getAttribute('data-active-guard') || 'web';
|
||||
guards.add(bodyGuard);
|
||||
}
|
||||
return Array.from(guards);
|
||||
}
|
||||
|
||||
notifyLivewireComponents(method) {
|
||||
document.querySelectorAll('[wire\\:id]').forEach(component => {
|
||||
const componentId = component.getAttribute('wire:id');
|
||||
if (componentId && component.classList.contains('user-presence-container')) {
|
||||
try {
|
||||
Livewire.find(componentId).call(method);
|
||||
} catch (e) {
|
||||
// component not available
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this.heartbeatInterval) {
|
||||
clearInterval(this.heartbeatInterval);
|
||||
}
|
||||
if (this.offlineTimeout) {
|
||||
clearTimeout(this.offlineTimeout);
|
||||
}
|
||||
this.setOffline();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize presence tracker
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.presenceTracker = new PresenceTracker();
|
||||
});
|
||||
|
||||
// Cleanup on page unload
|
||||
window.addEventListener('beforeunload', () => {
|
||||
if (window.presenceTracker) {
|
||||
window.presenceTracker.destroy();
|
||||
}
|
||||
});
|
||||
|
||||
export default PresenceTracker;
|
||||
@@ -0,0 +1,6 @@
|
||||
// Import Quill editor
|
||||
import Quill from 'quill';
|
||||
import 'quill/dist/quill.snow.css';
|
||||
|
||||
// Make Quill available globally for Alpine.js components
|
||||
window.Quill = Quill;
|
||||
@@ -0,0 +1,212 @@
|
||||
/**
|
||||
* Balance distribution of an organization's trading partners, on its own
|
||||
* Reports page.
|
||||
*
|
||||
* Reads counts per balance band from a data-* attribute, exactly as
|
||||
* stats-charts.js does. The data is aggregate only — a count per band per
|
||||
* profile type — so nothing here can identify an individual partner.
|
||||
*/
|
||||
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
/**
|
||||
* One chart per profile type, each on its own y-axis — the same split the
|
||||
* platform statistics page uses. Organizations are far fewer than users, so on
|
||||
* a shared axis their bars round away to nothing.
|
||||
*/
|
||||
const CHARTS = [
|
||||
{
|
||||
canvasId: 'reportBalanceDistributionChart',
|
||||
dataAttribute: 'data-report-balance-distribution',
|
||||
field: 'User',
|
||||
// teal-500, matching balance.User on the platform statistics page
|
||||
// so the same metric is the same colour wherever it appears.
|
||||
color: '#14b8a6',
|
||||
},
|
||||
{
|
||||
canvasId: 'reportBalanceDistributionOrgChart',
|
||||
dataAttribute: 'data-report-balance-distribution-org',
|
||||
field: 'Organization',
|
||||
// fuchsia-500, matching balance.Organization.
|
||||
color: '#d946ef',
|
||||
},
|
||||
];
|
||||
|
||||
function buildChart(config) {
|
||||
const canvas = document.getElementById(config.canvasId);
|
||||
if (!canvas || typeof Chart === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = canvas.closest(`[${config.dataAttribute}]`);
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = JSON.parse(wrapper.getAttribute(config.dataAttribute) || '[]');
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
let seriesLabels = {};
|
||||
try {
|
||||
seriesLabels = JSON.parse(wrapper.getAttribute('data-series-labels') || '{}');
|
||||
} catch (error) {
|
||||
seriesLabels = {};
|
||||
}
|
||||
|
||||
// Rebuild only when the data actually changed, so a Livewire morph does not
|
||||
// destroy and recreate a working chart on every interaction.
|
||||
const signature = wrapper.getAttribute(config.dataAttribute);
|
||||
if (canvas.dataset.chartSignature === signature && Chart.getChart?.(canvas)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = Chart.getChart ? Chart.getChart(canvas) : null;
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
}
|
||||
|
||||
// A single series per chart: the blade only renders this canvas when the
|
||||
// type has profiles, so there is nothing to filter here.
|
||||
const datasets = [{
|
||||
label: seriesLabels[config.field] || config.field,
|
||||
data: rows.map((row) => row[config.field] || 0),
|
||||
backgroundColor: config.color,
|
||||
borderColor: config.color,
|
||||
borderWidth: 1,
|
||||
}];
|
||||
|
||||
canvas.chart = new Chart(canvas.getContext('2d'), {
|
||||
type: 'bar',
|
||||
data: {
|
||||
labels: rows.map((row) => row.label),
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
layout: { padding: { bottom: 16 } },
|
||||
plugins: {
|
||||
// No legend: one series per chart, named by the heading above
|
||||
// it, so a one-item key would be pure noise.
|
||||
legend: { display: false },
|
||||
},
|
||||
scales: {
|
||||
y: { beginAtZero: true, ticks: { precision: 0 } },
|
||||
x: {
|
||||
// The axis keeps its offset, so every bar sits fully inside
|
||||
// the plot area — with offset:false the first and last bars
|
||||
// are centred on the axis edges and rendered half cut off.
|
||||
//
|
||||
// Only the gridlines move to the band boundaries, which is
|
||||
// what puts each bar between two lines. grid.offset is a
|
||||
// separate setting from the scale's own offset.
|
||||
grid: {
|
||||
display: true,
|
||||
drawOnChartArea: true,
|
||||
offset: false,
|
||||
color: 'rgba(0, 0, 0, 0.10)',
|
||||
},
|
||||
ticks: {
|
||||
padding: 8,
|
||||
autoSkip: false,
|
||||
maxRotation: 60,
|
||||
minRotation: 60,
|
||||
font: { size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
canvas.dataset.chartSignature = signature;
|
||||
}
|
||||
|
||||
function buildAll() {
|
||||
CHARTS.forEach(buildChart);
|
||||
}
|
||||
|
||||
// The canvas is rendered by Livewire, so DOMContentLoaded alone is too early:
|
||||
// it fires before the component's markup exists. Matching the pattern the other
|
||||
// report charts use — a short delay on load, plus livewire:init (which fires
|
||||
// before the first render) rather than livewire:initialized.
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(buildAll, 100);
|
||||
});
|
||||
|
||||
// Livewire replaces the canvas on re-render, so rebuild after each morph.
|
||||
document.addEventListener('livewire:init', () => {
|
||||
let morphTimer = null;
|
||||
Livewire.hook('morph.updated', () => {
|
||||
clearTimeout(morphTimer);
|
||||
morphTimer = setTimeout(buildAll, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// wire:navigate swaps the page without a fresh DOMContentLoaded.
|
||||
document.addEventListener('livewire:navigated', () => {
|
||||
setTimeout(buildAll, 100);
|
||||
});
|
||||
|
||||
/**
|
||||
* Render one distribution chart to an offscreen canvas for the PDF.
|
||||
*
|
||||
* Offscreen specifically: a Livewire morph can detach the live canvas mid
|
||||
* export, and DomPDF runs with isRemoteEnabled = false so the image has to be
|
||||
* inlined as a data URL.
|
||||
*/
|
||||
window.exportBalanceDistributionChartForPdf = async function (canvasId) {
|
||||
try {
|
||||
const canvas = document.getElementById(canvasId);
|
||||
if (!canvas) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const original = Chart.getChart ? Chart.getChart(canvas) : null;
|
||||
if (!original) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Rendered at the size the PDF displays it, times a device-pixel
|
||||
// factor. A 900x400 bitmap shown in a 674x300 slot is stretched
|
||||
// horizontally, which distorted every axis label.
|
||||
const PDF_SCALE = 2;
|
||||
const cssWidth = 674;
|
||||
const cssHeight = 300;
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = cssWidth * PDF_SCALE;
|
||||
offscreen.height = cssHeight * PDF_SCALE;
|
||||
|
||||
const offscreenChart = new Chart(offscreen.getContext('2d'), {
|
||||
type: original.config.type,
|
||||
data: JSON.parse(JSON.stringify(original.config.data)),
|
||||
options: {
|
||||
...JSON.parse(JSON.stringify(original.config.options || {})),
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
devicePixelRatio: PDF_SCALE,
|
||||
},
|
||||
});
|
||||
|
||||
// Give Chart.js a frame to paint before reading the bitmap.
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const image = offscreen.toDataURL('image/png', 1.0);
|
||||
offscreenChart.destroy();
|
||||
|
||||
return image;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,443 @@
|
||||
import { Chart, registerables } from 'chart.js';
|
||||
|
||||
Chart.register(...registerables);
|
||||
|
||||
window.initReturnRatioChart = function(timelineData, trendData = []) {
|
||||
const chartCanvas = document.getElementById('returnRatioChart');
|
||||
if (!chartCanvas || !timelineData || timelineData.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get data range for better debugging
|
||||
const values = timelineData.map(item => item.return_ratio);
|
||||
const minValue = Math.min(...values);
|
||||
const maxValue = Math.max(...values);
|
||||
|
||||
const ctx = chartCanvas.getContext('2d');
|
||||
if (!ctx) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy existing chart if it exists
|
||||
if (chartCanvas.chart) {
|
||||
chartCanvas.chart.destroy();
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Prepare datasets - trend line first to ensure it renders behind the main line
|
||||
const datasets = [];
|
||||
|
||||
// Add trend line first (renders behind)
|
||||
if (trendData && trendData.length > 0) {
|
||||
datasets.push({
|
||||
label: 'Trend Line',
|
||||
data: trendData.map(item => item.trend_value),
|
||||
borderColor: '#DC2626', // red-600 for better visibility
|
||||
backgroundColor: 'transparent',
|
||||
borderWidth: 3, // Increased width for better visibility
|
||||
borderDash: [8, 4], // Adjusted dash pattern for better visibility
|
||||
tension: 0, // Linear regression line (straight)
|
||||
fill: false,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 0,
|
||||
order: 1, // Render behind main line
|
||||
});
|
||||
}
|
||||
|
||||
// Add main return ratio line (renders on top)
|
||||
datasets.push({
|
||||
label: 'Reciprocity Rate %',
|
||||
data: timelineData.map(item => item.return_ratio),
|
||||
borderColor: '#000', // black
|
||||
backgroundColor: 'rgba(107, 114, 128, 0.1)', // Reduced opacity for less interference
|
||||
borderWidth: 3,
|
||||
fill: true,
|
||||
tension: 0.4,
|
||||
pointBackgroundColor: '#000', // black
|
||||
pointBorderColor: '#fff',
|
||||
pointBorderWidth: 2,
|
||||
pointRadius: 6,
|
||||
pointHoverRadius: 8,
|
||||
order: 0, // Render on top
|
||||
});
|
||||
|
||||
// Get translated labels from the first data point
|
||||
const translations = timelineData[0]?.translations || {
|
||||
period: 'Period',
|
||||
return_ratio: 'Reciprocity Rate'
|
||||
};
|
||||
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'line',
|
||||
data: {
|
||||
labels: timelineData.map(item => item.label),
|
||||
datasets: datasets
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
interaction: {
|
||||
intersect: false,
|
||||
mode: 'index'
|
||||
},
|
||||
plugins: {
|
||||
legend: {
|
||||
display: true,
|
||||
position: 'top',
|
||||
align: 'start',
|
||||
labels: {
|
||||
usePointStyle: true,
|
||||
color: '#1F2937'
|
||||
}
|
||||
},
|
||||
tooltip: {
|
||||
enabled: false,
|
||||
external: function(context) {
|
||||
const tooltip = context.tooltip;
|
||||
let tooltipEl = document.getElementById('custom-tooltip');
|
||||
|
||||
if (!tooltipEl) {
|
||||
tooltipEl = document.createElement('div');
|
||||
tooltipEl.id = 'custom-tooltip';
|
||||
tooltipEl.style.position = 'absolute';
|
||||
tooltipEl.style.background = 'rgba(0, 0, 0, 0.8)';
|
||||
tooltipEl.style.color = 'white';
|
||||
tooltipEl.style.border = '2px solid white';
|
||||
tooltipEl.style.borderRadius = '8px';
|
||||
tooltipEl.style.pointerEvents = 'none';
|
||||
tooltipEl.style.transform = 'translate(-50%, 0)';
|
||||
tooltipEl.style.transition = 'all .1s ease';
|
||||
tooltipEl.style.padding = '10px';
|
||||
tooltipEl.style.fontSize = '12px';
|
||||
tooltipEl.style.fontFamily = 'Arial, sans-serif';
|
||||
tooltipEl.style.zIndex = '9999';
|
||||
tooltipEl.style.boxShadow = 'none';
|
||||
tooltipEl.style.outline = 'none';
|
||||
document.body.appendChild(tooltipEl);
|
||||
}
|
||||
|
||||
if (tooltip.opacity === 0) {
|
||||
tooltipEl.style.opacity = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
if (tooltip.body) {
|
||||
const titleLines = tooltip.title || [];
|
||||
const bodyLines = tooltip.body.map(b => b.lines);
|
||||
|
||||
let innerHtml = '';
|
||||
|
||||
// Add title
|
||||
titleLines.forEach(function(title) {
|
||||
innerHtml += '<div style="font-weight: bold; margin-bottom: 5px;">' + title + '</div>';
|
||||
});
|
||||
|
||||
// Add body with color indicators
|
||||
bodyLines.forEach(function(body, i) {
|
||||
const colors = tooltip.labelColors[i];
|
||||
const colorBox = '<span style="display: inline-block; width: 12px; height: 12px; background-color: ' +
|
||||
colors.backgroundColor + '; border: 1px solid ' + colors.borderColor +
|
||||
'; margin-right: 5px; vertical-align: middle;"></span>';
|
||||
|
||||
// Fix the label based on dataset order (trend first, return ratio second)
|
||||
let label = body[0];
|
||||
if (i === 0) {
|
||||
// First dataset = Trend Line (red)
|
||||
label = 'Trend: ' + label.split(': ')[1];
|
||||
} else {
|
||||
// Second dataset = Reciprocity Rate (gray)
|
||||
label = 'Reciprocity Rate: ' + label.split(': ')[1];
|
||||
}
|
||||
|
||||
innerHtml += '<div style="margin: 2px 0;">' + colorBox + label + '</div>';
|
||||
});
|
||||
|
||||
tooltipEl.innerHTML = innerHtml;
|
||||
}
|
||||
|
||||
const position = context.chart.canvas.getBoundingClientRect();
|
||||
tooltipEl.style.opacity = '1';
|
||||
tooltipEl.style.left = position.left + window.pageXOffset + tooltip.caretX + 'px';
|
||||
tooltipEl.style.top = position.top + window.pageYOffset + tooltip.caretY + 'px';
|
||||
}
|
||||
}
|
||||
},
|
||||
scales: {
|
||||
x: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.period,
|
||||
color: '#6B7280'
|
||||
},
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280',
|
||||
// Every month keeps its label, angled so they fit.
|
||||
autoSkip: false,
|
||||
maxRotation: 60,
|
||||
minRotation: 60,
|
||||
padding: 8,
|
||||
font: { size: 10 }
|
||||
}
|
||||
},
|
||||
y: {
|
||||
display: true,
|
||||
title: {
|
||||
display: true,
|
||||
text: translations.return_ratio,
|
||||
color: '#6B7280'
|
||||
},
|
||||
beginAtZero: true,
|
||||
grid: {
|
||||
color: 'rgba(0, 0, 0, 0.1)'
|
||||
},
|
||||
ticks: {
|
||||
color: '#6B7280',
|
||||
callback: function(value) {
|
||||
return Math.round(value) + '%';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Store chart reference for cleanup
|
||||
chartCanvas.chart = chart;
|
||||
|
||||
// Store chart instance globally for PDF export
|
||||
window.returnRatioChart = chart;
|
||||
|
||||
// Set canvas height
|
||||
chartCanvas.style.height = '300px';
|
||||
|
||||
// Hide loading indicator
|
||||
const loadingIndicator = document.getElementById('chartLoadingIndicator');
|
||||
if (loadingIndicator) {
|
||||
loadingIndicator.style.display = 'none';
|
||||
}
|
||||
|
||||
return chart;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Livewire integration for chart initialization and PDF handling
|
||||
document.addEventListener('livewire:init', () => {
|
||||
Livewire.on('openPdf', (url) => {
|
||||
if (url) {
|
||||
window.open(url, '_blank');
|
||||
} else {
|
||||
}
|
||||
});
|
||||
|
||||
// Function to initialize chart
|
||||
function initializeChart() {
|
||||
|
||||
// Find chart container
|
||||
const chartContainer = document.querySelector('[data-chart-data]');
|
||||
if (!chartContainer) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get chart data
|
||||
const timelineDataAttr = chartContainer.getAttribute('data-chart-data');
|
||||
const trendDataAttr = chartContainer.getAttribute('data-trend-data');
|
||||
|
||||
if (!timelineDataAttr) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const timelineData = JSON.parse(timelineDataAttr);
|
||||
const trendData = trendDataAttr ? JSON.parse(trendDataAttr) : [];
|
||||
|
||||
// Initialize the chart
|
||||
if (typeof window.initReturnRatioChart === 'function') {
|
||||
window.initReturnRatioChart(timelineData, trendData);
|
||||
} else {
|
||||
}
|
||||
} catch (error) {
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize chart on page load
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeChart, 100);
|
||||
});
|
||||
|
||||
// Listen for Livewire updates - debounced
|
||||
let morphTimer = null;
|
||||
Livewire.hook('morph.updated', () => {
|
||||
clearTimeout(morphTimer);
|
||||
morphTimer = setTimeout(() => {
|
||||
initializeChart();
|
||||
}, 300);
|
||||
});
|
||||
});
|
||||
|
||||
// Function to export Return Ratio chart as base64 image for PDF
|
||||
window.exportReturnRatioChartForPdf = async function() {
|
||||
|
||||
if (!window.returnRatioChart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const originalChart = window.returnRatioChart;
|
||||
|
||||
// Render into an offscreen canvas to avoid touching the live DOM canvas
|
||||
const pdfWidth = 900;
|
||||
const pdfHeight = 400;
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = pdfWidth;
|
||||
offscreen.height = pdfHeight;
|
||||
const ctx = offscreen.getContext('2d');
|
||||
|
||||
const offscreenChart = new Chart(ctx, {
|
||||
type: originalChart.config.type,
|
||||
data: JSON.parse(JSON.stringify(originalChart.config.data)),
|
||||
options: {
|
||||
...JSON.parse(JSON.stringify(originalChart.config.options || {})),
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
}
|
||||
});
|
||||
|
||||
// Wait for render
|
||||
await new Promise(resolve => setTimeout(resolve, 300));
|
||||
|
||||
const chartImage = offscreen.toDataURL('image/png', 1.0);
|
||||
|
||||
offscreenChart.destroy();
|
||||
|
||||
return chartImage;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// Find the SingleReport Livewire component by looking for its unique DOM marker
|
||||
window.findSingleReportComponent = function() {
|
||||
if (!window.Livewire) return null;
|
||||
// Primary: use the dedicated id marker on the single-report root div
|
||||
const marker = document.getElementById('single-report-component');
|
||||
if (marker) {
|
||||
const wireElement = marker.closest('[wire\\:id]');
|
||||
if (wireElement) {
|
||||
return window.Livewire.find(wireElement.getAttribute('wire:id'));
|
||||
}
|
||||
}
|
||||
// Fallback: scan for unique child elements
|
||||
const elements = document.querySelectorAll('[wire\\:id]');
|
||||
for (let element of elements) {
|
||||
if (
|
||||
element.querySelector('[data-chart-data]') ||
|
||||
element.querySelector('[data-balance-chart-data]') ||
|
||||
element.querySelector('#returnRatioChart') ||
|
||||
element.querySelector('#accountBalancesChart') ||
|
||||
element.querySelector('#account-balances') ||
|
||||
element.querySelector('#transaction-types')
|
||||
) {
|
||||
return window.Livewire.find(element.getAttribute('wire:id'));
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// Function to export both charts and send to Livewire for PDF generation
|
||||
window.exportPdfWithChart = async function() {
|
||||
|
||||
// Export Return Ratio Chart
|
||||
const returnRatioChartImage = await window.exportReturnRatioChartForPdf();
|
||||
|
||||
// Export Account Balances Chart (if available)
|
||||
let accountBalancesChartImage = null;
|
||||
if (window.accountBalancesChart) {
|
||||
accountBalancesChartImage = await window.exportAccountBalancesChartForPdf();
|
||||
}
|
||||
|
||||
// Export the balance distribution charts (organizations and banks only —
|
||||
// the canvases are absent otherwise).
|
||||
let balanceDistributionChartImage = null;
|
||||
let balanceDistributionOrgChartImage = null;
|
||||
if (window.exportBalanceDistributionChartForPdf) {
|
||||
balanceDistributionChartImage =
|
||||
await window.exportBalanceDistributionChartForPdf('reportBalanceDistributionChart');
|
||||
balanceDistributionOrgChartImage =
|
||||
await window.exportBalanceDistributionChartForPdf('reportBalanceDistributionOrgChart');
|
||||
}
|
||||
|
||||
// If no chart at all is available, fall back to regular PDF export
|
||||
if (!returnRatioChartImage && !accountBalancesChartImage
|
||||
&& !balanceDistributionChartImage && !balanceDistributionOrgChartImage) {
|
||||
if (window.Livewire) {
|
||||
const component = window.findSingleReportComponent();
|
||||
if (component) {
|
||||
component.call('exportPdf');
|
||||
} else {
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
// Read date range and decimal format from DOM
|
||||
const chartContainer = document.querySelector('[data-balance-chart-data]') || document.querySelector('[data-chart-data]');
|
||||
let fromDate = null;
|
||||
let toDate = null;
|
||||
let decimalFormat = false;
|
||||
|
||||
if (chartContainer) {
|
||||
const dateRange = chartContainer.getAttribute('data-date-range');
|
||||
if (dateRange) {
|
||||
const dates = dateRange.split(' to ');
|
||||
if (dates.length === 2) {
|
||||
fromDate = dates[0].trim();
|
||||
toDate = dates[1].trim();
|
||||
}
|
||||
}
|
||||
decimalFormat = chartContainer.getAttribute('data-decimal-format') === '1';
|
||||
}
|
||||
|
||||
if (window.Livewire) {
|
||||
try {
|
||||
const component = window.findSingleReportComponent();
|
||||
if (!component) {
|
||||
throw new Error('SingleReport Livewire component not found');
|
||||
}
|
||||
await component.call(
|
||||
'exportPdfWithCharts',
|
||||
returnRatioChartImage,
|
||||
accountBalancesChartImage,
|
||||
fromDate,
|
||||
toDate,
|
||||
decimalFormat,
|
||||
balanceDistributionChartImage,
|
||||
balanceDistributionOrgChartImage,
|
||||
// Captured as rendered, so the PDF shows the same layout and
|
||||
// view the user is looking at.
|
||||
typeof window.exportNetworkForPdf === 'function'
|
||||
? window.exportNetworkForPdf()
|
||||
: null
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
const comp = window.findSingleReportComponent();
|
||||
if (comp) {
|
||||
await comp.call('exportPdf');
|
||||
}
|
||||
} catch (fallbackError) {
|
||||
}
|
||||
}
|
||||
} else {
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,103 @@
|
||||
let tagify;
|
||||
|
||||
window.addEventListener('remove', function () {
|
||||
if (tagify && tagify.value.length > 0) {
|
||||
tagify.removeTag(tagify.value[tagify.value.length - 1].value);
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
initializeTagify();
|
||||
|
||||
// Listen for custom event to update Tagify
|
||||
window.addEventListener('tagifyChange', function (e) {
|
||||
if (tagify) {
|
||||
tagify.loadOriginalValues(e.detail.tagsArray);
|
||||
}
|
||||
});
|
||||
|
||||
window.Livewire.on('disableSelect', () => {
|
||||
document.getElementById('select-translation').style.opacity = '0.4';
|
||||
document.getElementById('select-translation').style.cursor = 'pointer';
|
||||
document.getElementById('select-translation').style.pointerEvents = 'none';
|
||||
document.getElementById('input-translation').style.opacity = '1';
|
||||
});
|
||||
|
||||
window.Livewire.on('disableInput', () => {
|
||||
document.getElementById('input-translation').style.opacity = '0.4';
|
||||
document.getElementById('select-translation').style.cursor = 'default';
|
||||
document.getElementById('select-translation').style.pointerEvents = 'auto';
|
||||
document.getElementById('select-translation').style.opacity = '1';
|
||||
});
|
||||
});
|
||||
|
||||
function initializeTagify() {
|
||||
const input = document.getElementById('tags');
|
||||
|
||||
// Destroy existing Tagify instance if it exists
|
||||
if (tagify) {
|
||||
tagify.destroy();
|
||||
}
|
||||
|
||||
tagify = new Tagify(input, {
|
||||
pattern: /^.{3,80}$/, // max 80 characters, make sure also validation rule in Model is equally set
|
||||
maxTags: 50,
|
||||
autocapitalize: true,
|
||||
id: 'skillTags',
|
||||
whitelist: JSON.parse(input.dataset.suggestions),
|
||||
enforceWhiteList: false,
|
||||
backspace: false,
|
||||
editTags: false,
|
||||
addTagOn: ['blur', 'enter', 'tab'],
|
||||
autoComplete: {
|
||||
rightKey: true,
|
||||
tabKey: true,
|
||||
},
|
||||
dropdown: {
|
||||
classname: 'bg-theme-brand text-white',
|
||||
maxItems: 10, // maximum allowed rendered suggestions
|
||||
classname: 'readonlyMix', // Foreign tags are readonly and have a distinct appearance
|
||||
enabled: 3, // characters typed to show suggestions on focus
|
||||
position: 'text', // place the dropdown near the typed text
|
||||
closeOnSelect: true, // don't hide the dropdown when an item is selected
|
||||
highlightFirst: true, // highlight / suggest best match
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
tagify.on('dblclick', onChange);
|
||||
|
||||
|
||||
tagify.on('focus', () => {
|
||||
Livewire.dispatch('tagifyFocus');
|
||||
});
|
||||
|
||||
|
||||
tagify.on('blur', () => {
|
||||
Livewire.dispatch('tagifyBlur');
|
||||
});
|
||||
|
||||
|
||||
function onChange(e) {
|
||||
const component = Livewire.find(input.closest('[wire\\:id]').getAttribute('wire:id'));
|
||||
component.set('tagsArray', e.target.value);
|
||||
}
|
||||
|
||||
function onLoaded(e) {
|
||||
const tagsInput = document.getElementById('tags');
|
||||
if (tagsInput) {
|
||||
tagsInput.style.display = 'block';
|
||||
}
|
||||
onChange(e);
|
||||
}
|
||||
|
||||
|
||||
function onReloadPage() {
|
||||
window.location.reload();
|
||||
}
|
||||
|
||||
// input.addEventListener('change', onChange);
|
||||
window.addEventListener('load', onLoaded);
|
||||
window.addEventListener('reloadPage', onReloadPage);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,676 @@
|
||||
/**
|
||||
* Charts for the platform Stats view.
|
||||
*
|
||||
* Follows the same contract as return-ratio-chart.js / account-balances-chart.js:
|
||||
* data arrives via data-* attributes, charts re-initialise on Livewire morphs
|
||||
* (debounced), and PDF export renders to an offscreen canvas so a morph cannot
|
||||
* detach the live canvas mid-export.
|
||||
*/
|
||||
|
||||
// NOTE: globalName must never equal a canvas id — the browser auto-creates a
|
||||
// window.<id> reference to the element itself, which would shadow the chart
|
||||
// handle and make `.destroy()` blow up on a DOM node.
|
||||
const CHARTS = [
|
||||
{
|
||||
key: 'volume',
|
||||
canvasId: 'statsVolumeChart',
|
||||
dataAttribute: 'data-stats-volume-chart',
|
||||
globalName: 'statsChartVolume',
|
||||
// Stacked: each bar is one month's total volume, split by type.
|
||||
stacked: true,
|
||||
// Series are transaction-type names, which vary per platform, so they
|
||||
// come from the server rather than being listed here.
|
||||
dynamicSeries: true,
|
||||
},
|
||||
// One chart per profile type, each on its own y-axis: users peak around 60
|
||||
// a month where organizations peak at 2. Banks are excluded server-side —
|
||||
// a few setup-time banks would be lone spikes on a timeline about members
|
||||
// joining.
|
||||
{
|
||||
key: 'registrations',
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
pdfWidth: 331,
|
||||
pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng()
|
||||
canvasId: 'statsRegistrationsChart',
|
||||
dataAttribute: 'data-stats-registrations-chart',
|
||||
globalName: 'statsChartRegistrations',
|
||||
series: [{ field: 'User', label: 'User' }],
|
||||
},
|
||||
{
|
||||
key: 'registrationsOrg',
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
pdfWidth: 331,
|
||||
pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng()
|
||||
canvasId: 'statsRegistrationsOrgChart',
|
||||
dataAttribute: 'data-stats-registrations-org-chart',
|
||||
globalName: 'statsChartRegistrationsOrg',
|
||||
series: [{ field: 'Organization', label: 'Organization' }],
|
||||
},
|
||||
{
|
||||
// How many profiles stood marked as inactive at each month's end.
|
||||
// Cumulative, so a line rather than bars: these arrive in bursts (one
|
||||
// migration marked ~1.000 profiles inactive in a single month), and a
|
||||
// per-month bar chart of that is one spike beside years of zero.
|
||||
key: 'inactivations',
|
||||
type: 'line',
|
||||
// Full page width in the PDF — it sits alone on its row.
|
||||
pdfWidth: 674,
|
||||
pdfHeight: 200,
|
||||
canvasId: 'statsInactivationsChart',
|
||||
dataAttribute: 'data-stats-inactivations-chart',
|
||||
globalName: 'statsChartInactivations',
|
||||
series: [
|
||||
{ field: 'count', label: 'Profiles marked as inactive' },
|
||||
// Hours, not a count, so it gets the right-hand axis — the two
|
||||
// share a chart but not a unit.
|
||||
{ field: 'balance', label: 'Total balance', axis: 'y1', currency: true },
|
||||
// Cumulative, like the inactive count, now that incomplete_at
|
||||
// gives the state a timestamp.
|
||||
{ field: 'incomplete', label: 'Incomplete profiles' },
|
||||
],
|
||||
// A second axis in hours, drawn on the right.
|
||||
secondAxis: { id: 'y1', currency: true },
|
||||
// The running total hides the month's own additions; show them.
|
||||
// The label comes from data-footer-label, so it is translated.
|
||||
footerField: 'added',
|
||||
},
|
||||
{
|
||||
// Histogram: how many profiles sit in each balance band. Users and
|
||||
// organizations get one chart each, because organizations are so much
|
||||
// fewer that on a shared axis their bars round away to nothing.
|
||||
//
|
||||
// Banks are excluded server-side — a bank's balance is the mirror of
|
||||
// everyone else's and would sit alone in an outer band.
|
||||
key: 'balance',
|
||||
// Banded histogram: gridlines mark the band edges, not bar centres.
|
||||
bandedAxis: true,
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
// Full page width now that these stack one per row. Flatter than
|
||||
// 16:9 so both charts fit one page — the banded labels need width,
|
||||
// not height.
|
||||
pdfWidth: 674,
|
||||
pdfHeight: 200,
|
||||
canvasId: 'statsBalanceChart',
|
||||
dataAttribute: 'data-stats-balance-chart',
|
||||
globalName: 'statsChartBalance',
|
||||
series: [{ field: 'User', label: 'User' }],
|
||||
},
|
||||
{
|
||||
key: 'balanceOrg',
|
||||
// Banded histogram: gridlines mark the band edges, not bar centres.
|
||||
bandedAxis: true,
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
// Full page width now that these stack one per row. Flatter than
|
||||
// 16:9 so both charts fit one page — the banded labels need width,
|
||||
// not height.
|
||||
pdfWidth: 674,
|
||||
pdfHeight: 200,
|
||||
canvasId: 'statsBalanceOrgChart',
|
||||
dataAttribute: 'data-stats-balance-org-chart',
|
||||
globalName: 'statsChartBalanceOrg',
|
||||
series: [{ field: 'Organization', label: 'Organization' }],
|
||||
},
|
||||
// One chart per engagement metric, each on its own y-axis: reactions peak
|
||||
// around 3.900 a month where posts peak at 2, so a shared axis renders the
|
||||
// smaller series below a pixel and they read as missing.
|
||||
{
|
||||
key: 'engagementPosts',
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
pdfWidth: 331,
|
||||
pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng()
|
||||
canvasId: 'statsEngagementPostsChart',
|
||||
dataAttribute: 'data-stats-engagement-posts-chart',
|
||||
globalName: 'statsChartEngagementPosts',
|
||||
series: [{ field: 'posts', label: 'Posts' }],
|
||||
},
|
||||
{
|
||||
key: 'engagementCalls',
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
pdfWidth: 331,
|
||||
pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng()
|
||||
canvasId: 'statsEngagementCallsChart',
|
||||
dataAttribute: 'data-stats-engagement-calls-chart',
|
||||
globalName: 'statsChartEngagementCalls',
|
||||
series: [{ field: 'calls', label: 'Calls' }],
|
||||
},
|
||||
{
|
||||
key: 'engagementReactions',
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
pdfWidth: 331,
|
||||
pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng()
|
||||
canvasId: 'statsEngagementReactionsChart',
|
||||
dataAttribute: 'data-stats-engagement-reactions-chart',
|
||||
globalName: 'statsChartEngagementReactions',
|
||||
series: [{ field: 'reactions', label: 'Reactions' }],
|
||||
},
|
||||
{
|
||||
key: 'engagementMessages',
|
||||
// Half-width slot in the PDF; see chartToPng().
|
||||
pdfWidth: 331,
|
||||
pdfHeight: 124, // flatter than the screen's 16:9 — see chartToPng()
|
||||
canvasId: 'statsEngagementMessagesChart',
|
||||
dataAttribute: 'data-stats-engagement-messages-chart',
|
||||
globalName: 'statsChartEngagementMessages',
|
||||
series: [{ field: 'messages', label: 'Messages' }],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Fallback only. The real palette comes from the tag-category colours stored in
|
||||
* the database, passed in via data-series-colors so the chart matches the
|
||||
* colours used for tags elsewhere in the app.
|
||||
*/
|
||||
const SERIES_COLORS_FALLBACK = ['#f87171', '#4ade80', '#60a5fa', '#c084fc'];
|
||||
|
||||
/** How far to darken the palette — the raw tag colours are too bright for charts. */
|
||||
const COLOR_DIM_FACTOR = 0.72;
|
||||
|
||||
function hexToRgbParts(hex) {
|
||||
const clean = String(hex).replace('#', '');
|
||||
const full = clean.length === 3
|
||||
? clean.split('').map((c) => c + c).join('')
|
||||
: clean;
|
||||
|
||||
if (!/^[0-9a-f]{6}$/i.test(full)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
parseInt(full.slice(0, 2), 16),
|
||||
parseInt(full.slice(2, 4), 16),
|
||||
parseInt(full.slice(4, 6), 16),
|
||||
];
|
||||
}
|
||||
|
||||
/** Darken a hex colour toward black, leaving non-hex values untouched. */
|
||||
function dim(color, factor = COLOR_DIM_FACTOR) {
|
||||
const parts = hexToRgbParts(color);
|
||||
if (!parts) {
|
||||
return color;
|
||||
}
|
||||
const [r, g, b] = parts.map((v) => Math.round(v * factor));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
/** Translucent variant, used for line-chart fills. */
|
||||
function withAlpha(color, alpha) {
|
||||
const parts = hexToRgbParts(color);
|
||||
if (!parts) {
|
||||
return color;
|
||||
}
|
||||
const [r, g, b] = parts;
|
||||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||||
}
|
||||
|
||||
function themeColor(fallback) {
|
||||
const value = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue('--color-primary-500')
|
||||
.trim();
|
||||
return value ? `rgb(${value})` : fallback;
|
||||
}
|
||||
|
||||
function buildChart(config) {
|
||||
const canvas = document.getElementById(config.canvasId);
|
||||
if (!canvas || typeof window.Chart === 'undefined') {
|
||||
return;
|
||||
}
|
||||
|
||||
const wrapper = canvas.closest(`[${config.dataAttribute}]`);
|
||||
if (!wrapper) {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows;
|
||||
try {
|
||||
rows = JSON.parse(wrapper.getAttribute(config.dataAttribute) || '[]');
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!Array.isArray(rows) || rows.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const decimalFormat = wrapper.getAttribute('data-decimal-format') === '1';
|
||||
const signature = `${wrapper.getAttribute(config.dataAttribute)}|${decimalFormat}`;
|
||||
|
||||
// Nothing changed since the last build — leave the live chart alone. Without
|
||||
// this, every Livewire morph destroys a working chart and rebuilds it, and a
|
||||
// failure anywhere in between leaves an empty canvas behind.
|
||||
const liveChart = window.Chart.getChart?.(canvas);
|
||||
if (canvas.dataset.chartSignature === signature && liveChart) {
|
||||
// Re-point the global before bailing out. The PDF export reads charts
|
||||
// via window[globalName], and a morph that leaves the canvas untouched
|
||||
// would otherwise leave that reference stale — so every chart exported
|
||||
// as null and the PDF came out with no images at all.
|
||||
window[config.globalName] = liveChart;
|
||||
return;
|
||||
}
|
||||
|
||||
// Destroy whatever instance currently owns this canvas — not just the one we
|
||||
// last stored. Chart.js refuses to reuse a canvas that still belongs to
|
||||
// another chart, and a stale instance would silently block every re-render.
|
||||
const existing = window.Chart.getChart ? window.Chart.getChart(canvas) : null;
|
||||
if (existing) {
|
||||
existing.destroy();
|
||||
} else if (typeof window[config.globalName]?.destroy === 'function') {
|
||||
window[config.globalName].destroy();
|
||||
}
|
||||
|
||||
const isVolume = config.key === 'volume';
|
||||
// Each chart gets its own tag-category colour from the server so no two
|
||||
// charts on the page look alike; the theme grey is only a fallback.
|
||||
const color = dim(wrapper.getAttribute('data-chart-color') || themeColor('rgb(59, 130, 246)'));
|
||||
|
||||
// Mirror tbFormat()/the decimal toggle so the axis reads like every other
|
||||
// amount on the platform. Registrations are a plain profile count, so they
|
||||
// deliberately get no currency notation.
|
||||
const currencySymbol = wrapper.getAttribute('data-currency-symbol') || '';
|
||||
const currencyAtEnd = wrapper.getAttribute('data-currency-position-end') === '1';
|
||||
const thousandSeparator = wrapper.getAttribute('data-currency-thousand-separator') || '';
|
||||
|
||||
const groupThousands = (whole) => (thousandSeparator
|
||||
? String(whole).replace(/\B(?=(\d{3})+(?!\d))/g, thousandSeparator)
|
||||
: String(whole));
|
||||
|
||||
const withCurrency = (value) => {
|
||||
if (!currencySymbol) {
|
||||
return value;
|
||||
}
|
||||
return currencyAtEnd ? `${value} ${currencySymbol}` : `${currencySymbol} ${value}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {number} hours
|
||||
* @param {boolean} trimWholeHours drop the ":00" on exact hours (axis ticks)
|
||||
*/
|
||||
const formatHours = (hours, trimWholeHours = false) => {
|
||||
if (decimalFormat) {
|
||||
return withCurrency(`${hours.toFixed(2).replace('.', ',')} h.`);
|
||||
}
|
||||
const negative = hours < 0;
|
||||
const abs = Math.abs(hours);
|
||||
const wholeHours = Math.floor(abs);
|
||||
const minutes = Math.round((abs - wholeHours) * 60);
|
||||
// Guard the 59.6 -> :60 rounding case.
|
||||
const carried = minutes === 60 ? wholeHours + 1 : wholeHours;
|
||||
const rest = minutes === 60 ? 0 : minutes;
|
||||
const sign = negative ? '-' : '';
|
||||
const grouped = groupThousands(carried);
|
||||
|
||||
// Axis labels read better as "H 1.500" than "H 1.500:00". Only exact
|
||||
// hours are trimmed — a tick with real minutes still shows them, so no
|
||||
// value is ever displayed as something it isn't.
|
||||
if (trimWholeHours && rest === 0) {
|
||||
return withCurrency(`${sign}${grouped}`);
|
||||
}
|
||||
|
||||
return withCurrency(`${sign}${grouped}:${String(rest).padStart(2, '0')}`);
|
||||
};
|
||||
|
||||
// Multi-series charts (engagement) list their fields; single-series charts
|
||||
// derive one dataset. Amounts are stored in minutes, so volume shows hours.
|
||||
// Labels (locale-aware) and colours (tag-category palette) both come from
|
||||
// the server rather than being duplicated here.
|
||||
let seriesLabels = {};
|
||||
let seriesColors = {};
|
||||
try {
|
||||
seriesLabels = JSON.parse(wrapper.getAttribute('data-series-labels') || '{}');
|
||||
seriesColors = JSON.parse(wrapper.getAttribute('data-series-colors') || '{}');
|
||||
} catch (error) {
|
||||
seriesLabels = {};
|
||||
seriesColors = {};
|
||||
}
|
||||
|
||||
const chartType = config.type || 'bar';
|
||||
const isLine = chartType === 'line';
|
||||
|
||||
// Dynamic-series charts derive their series from the data keys (anything
|
||||
// that isn't a bookkeeping field), so new transaction types appear without
|
||||
// a code change.
|
||||
const series = config.dynamicSeries
|
||||
? Object.keys(rows[0] || {})
|
||||
.filter((key) => !['label', 'ym', 'count', 'volume'].includes(key))
|
||||
.map((field) => ({ field, label: field }))
|
||||
: config.series;
|
||||
|
||||
// Does anything actually sit on the second axis? When that series is all
|
||||
// zeroes it is dropped entirely, along with its axis: a flat line on the
|
||||
// baseline says nothing, and leaving the dataset while removing the scale
|
||||
// makes Chart.js invent an unconfigured axis (a stray -1..1 range).
|
||||
const secondAxisHasData = !config.secondAxis || (series || []).some(
|
||||
(serie) => serie.axis === config.secondAxis.id
|
||||
&& rows.some((row) => Number(row[serie.field]) !== 0)
|
||||
);
|
||||
|
||||
const chartedSeries = (series || []).filter(
|
||||
(serie) => secondAxisHasData || !config.secondAxis || serie.axis !== config.secondAxis.id
|
||||
);
|
||||
|
||||
const datasets = series
|
||||
? chartedSeries.map((serie, index) => {
|
||||
const rawColor = seriesColors[serie.field]
|
||||
|| SERIES_COLORS_FALLBACK[index % SERIES_COLORS_FALLBACK.length];
|
||||
const serieColor = dim(rawColor);
|
||||
|
||||
return {
|
||||
label: seriesLabels[serie.field] || serie.label,
|
||||
data: rows.map((row) => row[serie.field] || 0),
|
||||
// A line needs a visible stroke and a translucent fill; bars use
|
||||
// the flat colour for both.
|
||||
backgroundColor: isLine ? withAlpha(rawColor, 0.15) : serieColor,
|
||||
borderColor: serieColor,
|
||||
borderWidth: isLine ? 2 : 1,
|
||||
...(isLine ? {
|
||||
// stepLike series keep straight segments: Bézier smoothing
|
||||
// overshoots at a sharp corner and draws a rise just before
|
||||
// a drop that never happened.
|
||||
tension: config.stepLike === true ? 0 : 0.3,
|
||||
// No dots: with a point per month the line reads as a set
|
||||
// of readings rather than a continuous quantity. The hover
|
||||
// marker still appears, so values stay inspectable.
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 4,
|
||||
fill: false,
|
||||
} : {}),
|
||||
...(serie.axis ? { yAxisID: serie.axis } : {}),
|
||||
...(serie.dashed ? { borderDash: [6, 4] } : {}),
|
||||
};
|
||||
})
|
||||
: [{
|
||||
label: isVolume ? 'volume' : 'count',
|
||||
data: rows.map((row) => (isVolume ? (row.volume || 0) / 60 : row.count || 0)),
|
||||
backgroundColor: color,
|
||||
borderColor: color,
|
||||
borderWidth: 1,
|
||||
}];
|
||||
|
||||
window[config.globalName] = new window.Chart(canvas.getContext('2d'), {
|
||||
type: chartType,
|
||||
data: {
|
||||
labels: rows.map((row) => row.label),
|
||||
datasets,
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
// Descenders in the tick labels (Jan, Aug, Sep) sit right against
|
||||
// the legend below them, so the axis gets its own breathing room
|
||||
// rather than relying on the canvas padding alone.
|
||||
layout: { padding: { bottom: 16 } },
|
||||
plugins: {
|
||||
// A legend only earns its space when there is more than one
|
||||
// series to tell apart. The balance charts are one series each
|
||||
// under their own heading, so a one-item key is pure noise.
|
||||
legend: datasets.length > 1
|
||||
? { display: true, position: 'bottom', align: 'start', labels: { boxWidth: 12, usePointStyle: true, padding: 16 } }
|
||||
: { display: false },
|
||||
tooltip: {
|
||||
// Gather every segment of the hovered bar, so the stacked
|
||||
// total in the footer reflects the whole column.
|
||||
mode: config.stacked === true ? 'index' : 'nearest',
|
||||
intersect: config.stacked !== true,
|
||||
callbacks: {
|
||||
// The axis shows each band's lower bound only, so the
|
||||
// tooltip carries the full range the bar covers.
|
||||
title: (items) => {
|
||||
const row = rows[items[0]?.dataIndex];
|
||||
return row?.range || items[0]?.label || '';
|
||||
},
|
||||
label: (context) => {
|
||||
const value = context.parsed.y;
|
||||
// Volume series are hours and need currency notation
|
||||
// even when the chart is split per type.
|
||||
// A per-series currency flag covers the mixed-unit
|
||||
// chart, where only one of the two is hours.
|
||||
const serieMeta = chartedSeries?.[context.datasetIndex];
|
||||
const asHours = isVolume || serieMeta?.currency === true;
|
||||
const shown = asHours ? formatHours(value) : `${value}`;
|
||||
// Same reasoning as the legend: naming the series
|
||||
// only helps when there is more than one.
|
||||
return datasets.length > 1 ? `${context.dataset.label}: ${shown}` : shown;
|
||||
},
|
||||
// On a stacked chart the bar height is the month's
|
||||
// total, so show it — otherwise the reader can only see
|
||||
// the segment they happen to be hovering.
|
||||
footer: (items) => {
|
||||
if (items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
// Cumulative charts: the plotted value is a running
|
||||
// total, so the month's own change is invisible
|
||||
// without saying it. A flat stretch then reads as
|
||||
// "nothing happened" rather than as missing data.
|
||||
if (config.footerField) {
|
||||
const row = rows[items[0].dataIndex];
|
||||
const added = row?.[config.footerField] || 0;
|
||||
// Translated server-side; the bare number is a
|
||||
// safe fallback if the attribute is absent.
|
||||
const footerLabel = wrapper.getAttribute('data-footer-label');
|
||||
return footerLabel ? `${footerLabel}: +${added}` : `+${added}`;
|
||||
}
|
||||
|
||||
if (config.stacked !== true) {
|
||||
return undefined;
|
||||
}
|
||||
const total = items.reduce((sum, item) => sum + (item.parsed.y || 0), 0);
|
||||
return `${'\u2211'} ${isVolume ? formatHours(total) : total}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
y: {
|
||||
beginAtZero: true,
|
||||
// Volume stacks: the transaction types partition one month's
|
||||
// total, so the stacked height is a real quantity. The other
|
||||
// charts count unrelated things, where a stack would invent
|
||||
// a total that means nothing.
|
||||
stacked: config.stacked === true,
|
||||
ticks: isVolume
|
||||
? { callback: (value) => formatHours(value, true) }
|
||||
: { precision: 0 },
|
||||
},
|
||||
// Opt-in right-hand axis, for a chart plotting two different
|
||||
// units — a count and an amount of hours — where a shared
|
||||
// scale would make the smaller series unreadable.
|
||||
//
|
||||
// Hidden when every value on it is zero: Chart.js then
|
||||
// autoscales to an arbitrary tiny range (H 0 to H 1 in
|
||||
// six-minute steps), which reads as a real measurement of
|
||||
// something when it is just an empty series.
|
||||
...(config.secondAxis && secondAxisHasData ? {
|
||||
[config.secondAxis.id]: {
|
||||
position: 'right',
|
||||
beginAtZero: true,
|
||||
// The left axis owns the gridlines; a second set drawn
|
||||
// at different intervals just adds visual noise.
|
||||
grid: { drawOnChartArea: false },
|
||||
ticks: config.secondAxis.currency
|
||||
? { callback: (value) => formatHours(value, true) }
|
||||
: { precision: 0 },
|
||||
},
|
||||
} : {}),
|
||||
x: {
|
||||
stacked: config.stacked === true,
|
||||
// The scale keeps its offset so no bar is clipped at the
|
||||
// axis edges; only grid.offset below moves the lines onto
|
||||
// the band boundaries.
|
||||
// Vertical gridlines everywhere: they tie each label to its
|
||||
// bar, which is the whole point of labelling every one.
|
||||
grid: {
|
||||
display: true,
|
||||
drawOnChartArea: true,
|
||||
// Histograms (bandedAxis) put lines on the band
|
||||
// boundaries so each bar sits between two; timelines
|
||||
// keep centred lines, since a month label belongs under
|
||||
// its own bar.
|
||||
offset: config.bandedAxis !== true,
|
||||
color: 'rgba(0, 0, 0, 0.10)',
|
||||
},
|
||||
ticks: {
|
||||
padding: 8,
|
||||
// Every bar keeps its label. autoSkip dropped two in
|
||||
// three, leaving more bars than labels and no way to
|
||||
// tell which was which — so labels turn vertical
|
||||
// instead, which also costs no horizontal space.
|
||||
autoSkip: false,
|
||||
// 60 degrees: readable at a glance without the neck
|
||||
// tilt of fully vertical text. The labels are short
|
||||
// enough now (a single bound, not a range) that they
|
||||
// fit at this angle.
|
||||
maxRotation: 60,
|
||||
minRotation: 60,
|
||||
font: { size: 10 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
canvas.dataset.chartSignature = signature;
|
||||
}
|
||||
|
||||
function initializeStatsCharts() {
|
||||
CHARTS.forEach((config) => {
|
||||
try {
|
||||
buildChart(config);
|
||||
} catch (error) {
|
||||
// Never break the rest of the page, but don't fail silently either —
|
||||
// an empty chart box is otherwise impossible to diagnose.
|
||||
console.error(`[stats-charts] ${config.canvasId} failed to render:`, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
setTimeout(initializeStatsCharts, 100);
|
||||
});
|
||||
|
||||
document.addEventListener('livewire:init', () => {
|
||||
let morphTimer = null;
|
||||
Livewire.hook('morph.updated', () => {
|
||||
clearTimeout(morphTimer);
|
||||
morphTimer = setTimeout(initializeStatsCharts, 300);
|
||||
});
|
||||
|
||||
Livewire.on('openStatsPdf', (url) => {
|
||||
window.open(Array.isArray(url) ? url[0] : url, '_blank');
|
||||
});
|
||||
});
|
||||
|
||||
/** Render one live chart into an offscreen canvas and return a PNG data URL. */
|
||||
async function chartToPng(config) {
|
||||
// Prefer the live instance owning the canvas over the global: the global is
|
||||
// a convenience handle and can go stale, while Chart.getChart() is the
|
||||
// authority on what is actually rendered.
|
||||
const canvas = document.getElementById(config.canvasId);
|
||||
const chart = (canvas && window.Chart.getChart?.(canvas)) || window[config.globalName];
|
||||
|
||||
if (!chart) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
try {
|
||||
// Render at the size the PDF actually displays, times a device-pixel
|
||||
// factor. Deliberately flatter than the 16:9 used on screen: a print
|
||||
// report has a fixed page budget, and 16:9 here runs the export to a
|
||||
// third page for no gain — the shape suits a 12-month timeline anyway. A 900px-wide bitmap shown in a 331px column is downscaled
|
||||
// 2.7x, which is what made the axis labels unreadable — and forcing a
|
||||
// fixed height onto that aspect ratio squashed the plot.
|
||||
const PDF_SCALE = 2;
|
||||
const cssWidth = config.pdfWidth || 674;
|
||||
const cssHeight = config.pdfHeight || 180;
|
||||
|
||||
const offscreen = document.createElement('canvas');
|
||||
offscreen.width = cssWidth * PDF_SCALE;
|
||||
offscreen.height = cssHeight * PDF_SCALE;
|
||||
|
||||
// Named exportConfig, not config: the parameter is already called that.
|
||||
const exportConfig = JSON.parse(JSON.stringify({
|
||||
type: chart.config.type,
|
||||
data: chart.config.data,
|
||||
options: chart.config.options,
|
||||
}));
|
||||
|
||||
exportConfig.options = {
|
||||
...exportConfig.options,
|
||||
responsive: false,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
devicePixelRatio: PDF_SCALE,
|
||||
};
|
||||
|
||||
const offscreenChart = new window.Chart(offscreen.getContext('2d'), exportConfig);
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const image = offscreen.toDataURL('image/png', 1.0);
|
||||
offscreenChart.destroy();
|
||||
|
||||
return image;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function findStatsComponent() {
|
||||
if (!window.Livewire) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const root = document.getElementById('stats-component');
|
||||
const element = root ? root.closest('[wire\\:id]') || root : null;
|
||||
|
||||
if (element && element.getAttribute('wire:id')) {
|
||||
return window.Livewire.find(element.getAttribute('wire:id'));
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
window.exportStatsPdf = async function () {
|
||||
const component = findStatsComponent();
|
||||
if (!component) {
|
||||
return;
|
||||
}
|
||||
|
||||
const images = {};
|
||||
for (const config of CHARTS) {
|
||||
const png = await chartToPng(config);
|
||||
if (png) {
|
||||
images[config.key] = png;
|
||||
}
|
||||
}
|
||||
|
||||
// The trading network lives in its own module and is captured as rendered,
|
||||
// so the PDF shows the same layout and view the user is looking at.
|
||||
if (typeof window.exportNetworkForPdf === 'function') {
|
||||
const network = window.exportNetworkForPdf();
|
||||
|
||||
if (network) {
|
||||
images.network = network;
|
||||
}
|
||||
}
|
||||
|
||||
// The counts sentence under the caption is computed by the classifier, in
|
||||
// the browser, so the server cannot rebuild it for the PDF. Sent with the
|
||||
// images because that is the channel that already exists; the component
|
||||
// splits it back out.
|
||||
const host = document.querySelector('[data-network-host]');
|
||||
|
||||
if (host && host.dataset.networkSubtext) {
|
||||
images.networkSubtext = host.dataset.networkSubtext;
|
||||
}
|
||||
|
||||
try {
|
||||
await component.call('exportPdf', images);
|
||||
} catch (error) {
|
||||
// Fall back to a data-only PDF.
|
||||
try {
|
||||
await component.call('exportPdf', {});
|
||||
} catch (innerError) {
|
||||
// Nothing further we can do client-side.
|
||||
}
|
||||
}
|
||||
};
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user