/**
* Scene Info Grid Widget
*
* Displays calendar, weather, temperature, clock, and location in a compact
* information-dense grid layout. All data points visible at once for maximum
* scannability.
*
* Design: 2-column grid with location header + 4 data cards
* Inspiration: Apple Widgets, Material Design, modern dashboard patterns
*/
import { parseInfoBoxData } from './infoBoxWidgets.js';
/**
* Format date for display
* @param {string} fullDate - Full date string from infoBox
* @param {string} weekday - Weekday name
* @param {string} month - Month/day description (e.g. "3rd Day of the Ninth Month")
* @returns {Object} Formatted date parts
*/
function formatDate(fullDate, weekday, month) {
if (!fullDate && !month) {
return { icon: 'π
', value: 'No Date', label: '' };
}
// parseInfoBoxData splits date on commas:
// "Tuesday, 3rd Day of the Ninth Month, Autumn, Year..." becomes:
// weekday = "Tuesday"
// month = "3rd Day of the Ninth Month"
// year = "Autumn"
// Display the most important part (month/day) with weekday as label
const displayValue = month || fullDate;
const displayLabel = weekday || '';
return {
icon: 'π
',
value: displayValue,
label: displayLabel
};
}
/**
* Format time for display
* @param {string} timeStart - Start time
* @param {string} timeEnd - End time
* @returns {Object} Formatted time parts
*/
function formatTime(timeStart, timeEnd) {
const timeDisplay = timeEnd || timeStart || '12:00';
return {
icon: 'π',
value: timeDisplay,
label: '' // Could add timezone if available
};
}
/**
* Format weather for display
* @param {string} weatherEmoji - Weather emoji or symbol string
* @param {string} weatherForecast - Weather description
* @returns {Object} Formatted weather parts
*/
function formatWeather(weatherEmoji, weatherForecast) {
const forecast = weatherForecast || 'Clear';
// If no emoji provided, display forecast text only
if (!weatherEmoji) {
return {
icon: '',
value: forecast,
label: ''
};
}
// Validate emoji/symbol (relaxed check)
// Allow: actual emojis, custom symbols (+++, ***, etc.)
const emojiRegex = /[\u{1F300}-\u{1F9FF}\u{2600}-\u{26FF}\u{2700}-\u{27BF}]/u;
const symbolRegex = /^[+*#~\-=_]+$/; // Custom weather symbols
const looksLikeEmojiOrSymbol = weatherEmoji.length <= 5 && (
emojiRegex.test(weatherEmoji) ||
symbolRegex.test(weatherEmoji)
);
if (looksLikeEmojiOrSymbol) {
// Valid emoji or symbol - append to forecast
return {
icon: '',
value: `${forecast} ${weatherEmoji}`,
label: ''
};
} else {
// weatherEmoji is actually text (e.g., "Clear") - combine with forecast
// Handles: prose weather like "The air crackles with magical energy"
return {
icon: '',
value: `${weatherEmoji} ${forecast}`.trim(),
label: ''
};
}
}
/**
* Format temperature for display
* @param {string} temperature - Temperature value
* @returns {Object} Formatted temperature parts
*/
function formatTemp(temperature) {
if (!temperature) {
return { icon: 'π‘οΈ', value: '20Β°C', label: '' };
}
return {
icon: 'π‘οΈ',
value: temperature,
label: '' // Could add "Feels like" if available
};
}
/**
* Format location for display
* @param {string} location - Location name
* @returns {Object} Formatted location parts
*/
function formatLocation(location) {
if (!location || location === 'Location') {
return { value: 'No Location', label: '' };
}
// Split on FIRST comma only to get primary location + context
// Preserves hyphens in names (e.g., "Seol Yi-hwan")
// Example: "The Winding Stair, Third Floor, East Wing, Palace, City"
// -> value: "The Winding Stair", label: "Third Floor, East Wing, Palace, City"
const firstCommaIndex = location.indexOf(',');
if (firstCommaIndex !== -1 && firstCommaIndex < location.length - 1) {
return {
value: location.substring(0, firstCommaIndex).trim(),
label: location.substring(firstCommaIndex + 1).trim() // Keep all remaining text
};
}
// No comma or comma at end - display full text
return {
value: location,
label: ''
};
}
/**
* Render info grid item
* @param {Object} item - Item data
* @param {string} item.icon - Icon emoji (optional)
* @param {string} item.value - Primary value
* @param {string} item.label - Secondary label
* @param {string} field - Field name for editing
* @param {string} gridArea - CSS grid area name
* @returns {string} HTML for grid item
*/
function renderInfoItem(item, field, gridArea) {
const hasLabel = item.label && item.label !== '';
const hasIcon = item.icon && item.icon !== '';
const areaClass = gridArea ? `rpg-info-${gridArea}` : '';
return `
${hasIcon ? `
${item.icon}` : ''}
${item.value}
${hasLabel ? `${item.label}` : ''}
`;
}
/**
* Render location header (full width)
* @param {Object} location - Location data
* @returns {string} HTML for location header
*/
function renderLocationHeader(location) {
const hasDescription = location.label && location.label !== '';
return `
π
${location.value}
${hasDescription ? `${location.label}` : ''}
`;
}
/**
* Attach edit handlers to editable fields
* @param {HTMLElement} container - Widget container
* @param {Object} dependencies - Widget dependencies
*/
function attachEditHandlers(container, dependencies) {
const editableFields = container.querySelectorAll('.rpg-editable');
editableFields.forEach(field => {
const fieldName = field.dataset.field;
let originalValue = field.textContent.trim();
field.addEventListener('focus', () => {
originalValue = field.textContent.trim();
// Select all text on focus
const range = document.createRange();
range.selectNodeContents(field);
const selection = window.getSelection();
selection.removeAllRanges();
selection.addRange(range);
});
field.addEventListener('blur', () => {
const value = field.textContent.trim();
if (value && value !== originalValue) {
updateInfoBoxField(dependencies, fieldName, value);
}
});
field.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
field.blur();
}
if (e.key === 'Escape') {
e.preventDefault();
field.textContent = originalValue;
field.blur();
}
});
// Prevent paste with formatting
field.addEventListener('paste', (e) => {
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
});
}
/**
* Update info box field in shared data
* @param {Object} dependencies - Widget dependencies
* @param {string} field - Field name
* @param {string} value - New value
*/
function updateInfoBoxField(dependencies, field, value) {
const { getInfoBoxData, setInfoBoxData, onDataChange } = dependencies;
let infoBoxData = getInfoBoxData() || '';
// Simple replace for now - could be more sophisticated
const fieldMap = {
'date': /Date: [^\n]+/,
'time': /Time: [^\n]+/,
'weather': /Weather: [^\n]+/,
'temperature': /Temperature: [^\n]+/,
'location': /Location: [^\n]+/
};
const pattern = fieldMap[field];
if (pattern) {
const replacement = `${field.charAt(0).toUpperCase() + field.slice(1)}: ${value}`;
if (pattern.test(infoBoxData)) {
infoBoxData = infoBoxData.replace(pattern, replacement);
} else {
infoBoxData += `\n${replacement}`;
}
setInfoBoxData(infoBoxData);
if (onDataChange) {
onDataChange('infoBox', field, value);
}
}
}
/**
* Register Scene Info Widget
*/
export function registerSceneInfoWidget(registry, dependencies) {
registry.register('sceneInfo', {
name: 'Scene Info',
icon: 'πΊοΈ',
description: 'Compact scene information grid (calendar, weather, time, location)',
category: 'scene',
minSize: { w: 2, h: 2 },
// Column-aware sizing: compact on mobile, spacious on desktop
defaultSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 2 }; // Mobile: 2Γ2 (compact, full width)
}
return { w: 3, h: 3 }; // Desktop: 3Γ3 (spacious)
},
maxAutoSize: (columns) => {
if (columns <= 2) {
return { w: 2, h: 3 }; // Mobile: 2Γ3 max (full width)
}
return { w: 3, h: 3 }; // Desktop: 3Γ3 max
},
requiresSchema: false,
/**
* Render the widget
* @param {HTMLElement} container - Widget container
* @param {Object} config - Widget configuration
*/
render(container, config = {}) {
const { getInfoBoxData } = dependencies;
const data = parseInfoBoxData(getInfoBoxData());
// Format data for display
const date = formatDate(data.date, data.weekday, data.month);
const time = formatTime(data.timeStart, data.timeEnd);
const weather = formatWeather(data.weatherEmoji, data.weatherForecast);
const temp = formatTemp(data.temperature);
const location = formatLocation(data.location);
// Build grid HTML
const html = `
${renderLocationHeader(location)}
${renderInfoItem(date, 'date', 'calendar')}
${renderInfoItem(time, 'time', 'clock')}
${renderInfoItem(weather, 'weather', 'weather')}
${renderInfoItem(temp, 'temperature', 'temperature')}
`;
container.innerHTML = html;
attachEditHandlers(container, dependencies);
},
/**
* Get configuration options
* @returns {Object} Configuration schema
*/
getConfig() {
return {
showLabels: {
type: 'boolean',
label: 'Show Secondary Labels',
default: true,
description: 'Show secondary text (weekday, timezone, etc.)'
},
compactMode: {
type: 'boolean',
label: 'Compact Mode',
default: false,
description: 'Reduce padding and font sizes'
}
};
},
/**
* Handle configuration changes
* @param {HTMLElement} container - Widget container
* @param {Object} newConfig - New configuration
*/
onConfigChange(container, newConfig) {
this.render(container, newConfig);
}
});
}