Giant refactor. added layers. ui overhaul. added save/load and we now got presets
This commit is contained in:
Sam
2025-12-28 03:21:25 +13:00
parent f01076df57
commit 14ec23237f
90 changed files with 4971 additions and 22901 deletions

View File

@@ -0,0 +1,667 @@
/**
* ControlFactory - Creates DOM elements for different control types
*
* Separated from ControlManager for cleaner code organization
* Features collapsible settings panels and inline filter configuration
*/
class ControlFactory {
constructor(controlManager) {
this.controlManager = controlManager;
}
/**
* Create a control based on type
* @param {object} config - Control configuration
* @param {object} instance - Shape instance to bind to
* @param {HTMLElement} container - Container element
* @returns {object} Control data { element, listener, type, config }
*/
create(config, instance, container) {
switch (config.type) {
case 'range':
return this.createRange(config, instance, container);
case 'color':
return this.createColor(config, instance, container);
case 'checkbox':
return this.createCheckbox(config, instance, container);
case 'dropdown':
return this.createDropdown(config, instance, container);
case 'button':
return this.createButton(config, instance, container);
case 'header':
return this.createHeader(config, container);
default:
console.warn(`Unknown control type: ${config.type}`);
return null;
}
}
/**
* Create a collapsible section with a toggle arrow
*/
createCollapsible(title, className, defaultOpen = false) {
const wrapper = document.createElement('div');
wrapper.className = `collapsible-section ${className}`;
const header = document.createElement('div');
header.className = 'collapsible-header';
const arrow = document.createElement('span');
arrow.className = 'collapsible-arrow';
arrow.textContent = defaultOpen ? '▼' : '▶';
const titleSpan = document.createElement('span');
titleSpan.className = 'collapsible-title';
titleSpan.textContent = title;
header.appendChild(arrow);
header.appendChild(titleSpan);
const content = document.createElement('div');
content.className = 'collapsible-content';
content.style.display = defaultOpen ? 'block' : 'none';
header.addEventListener('click', () => {
const isOpen = content.style.display !== 'none';
content.style.display = isOpen ? 'none' : 'block';
arrow.textContent = isOpen ? '▶' : '▼';
});
wrapper.appendChild(header);
wrapper.appendChild(content);
return { wrapper, content, header, arrow };
}
/**
* Create a range slider control with collapsible settings and filters
*/
createRange(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container control-range-container';
// Main row: label + value + slider
const mainRow = document.createElement('div');
mainRow.className = 'control-main-row';
// Label showing property name
const label = document.createElement('span');
label.className = 'control-label-inline';
label.textContent = config.property;
// Value display (editable)
const valueDisplay = document.createElement('span');
valueDisplay.className = 'control-value';
valueDisplay.id = `elText${config.property}`;
valueDisplay.textContent = instance[config.property] ?? config.defaultValue;
// Range input
const input = document.createElement('input');
input.type = 'range';
input.className = 'control control-range';
input.id = `el${config.property}`;
input.min = config.min;
input.max = config.max;
input.value = instance[config.property] ?? config.defaultValue;
// Store original config bounds for reference
const configState = {
min: config.min,
max: config.max,
originalMin: config.min,
originalMax: config.max
};
// Event listener
const listener = (event) => {
const newValue = parseFloat(event.target.value);
instance[config.property] = newValue;
valueDisplay.textContent = Math.round(newValue * 100) / 100;
if (config.callback) {
config.callback(instance, newValue);
}
};
input.addEventListener('input', listener);
mainRow.appendChild(label);
mainRow.appendChild(valueDisplay);
wrapper.appendChild(mainRow);
wrapper.appendChild(input);
// Options row with toggle buttons
const optionsRow = document.createElement('div');
optionsRow.className = 'control-options-row';
// Settings toggle button
const settingsBtn = document.createElement('button');
settingsBtn.className = 'control-toggle-btn';
settingsBtn.innerHTML = '⚙';
settingsBtn.title = 'Settings';
// Filters toggle button
const filtersBtn = document.createElement('button');
filtersBtn.className = 'control-toggle-btn';
filtersBtn.innerHTML = '◇';
filtersBtn.title = 'Filters';
optionsRow.appendChild(settingsBtn);
optionsRow.appendChild(filtersBtn);
wrapper.appendChild(optionsRow);
// Settings panel (hidden by default)
const settingsPanel = document.createElement('div');
settingsPanel.className = 'control-settings-panel';
settingsPanel.style.display = 'none';
// Min setting
const minRow = this.createSettingRow('Min', configState.min, (val) => {
configState.min = val;
input.min = val;
});
// Max setting
const maxRow = this.createSettingRow('Max', configState.max, (val) => {
configState.max = val;
input.max = val;
});
settingsPanel.appendChild(minRow);
settingsPanel.appendChild(maxRow);
wrapper.appendChild(settingsPanel);
// Settings toggle
settingsBtn.addEventListener('click', () => {
const isOpen = settingsPanel.style.display !== 'none';
settingsPanel.style.display = isOpen ? 'none' : 'block';
settingsBtn.classList.toggle('active', !isOpen);
});
// Filters panel (hidden by default)
const filtersPanel = document.createElement('div');
filtersPanel.className = 'control-filters-panel';
filtersPanel.style.display = 'none';
// Add filter dropdown
const addFilterRow = document.createElement('div');
addFilterRow.className = 'add-filter-row';
const filterSelect = document.createElement('select');
filterSelect.className = 'filter-type-select';
filterSelect.innerHTML = '<option value="">+ Add Filter...</option>';
const filters = this.controlManager.filterManager.getAvailableFilters();
for (const filter of filters) {
const opt = document.createElement('option');
opt.value = filter.id;
opt.textContent = filter.name;
filterSelect.appendChild(opt);
}
addFilterRow.appendChild(filterSelect);
filtersPanel.appendChild(addFilterRow);
// Container for active filters
const filtersContainer = document.createElement('div');
filtersContainer.className = 'filters-list';
filtersPanel.appendChild(filtersContainer);
wrapper.appendChild(filtersPanel);
// Filters toggle
filtersBtn.addEventListener('click', () => {
const isOpen = filtersPanel.style.display !== 'none';
filtersPanel.style.display = isOpen ? 'none' : 'block';
filtersBtn.classList.toggle('active', !isOpen);
});
container.appendChild(wrapper);
const controlData = {
element: input,
listener,
type: 'range',
config,
configState,
wrapper,
filtersContainer,
filters: [],
label,
valueDisplay,
settingsPanel,
filtersPanel,
filtersBtn
};
// Filter dropdown handler
filterSelect.addEventListener('change', () => {
if (filterSelect.value) {
this.controlManager.addFilter(controlData, config, filterSelect.value);
filterSelect.value = ''; // Reset dropdown
// Update filter button to show filter count
this.updateFilterBadge(controlData);
}
});
return controlData;
}
/**
* Create a setting row with label and number input
*/
createSettingRow(label, value, onChange) {
const row = document.createElement('div');
row.className = 'setting-row';
const labelEl = document.createElement('span');
labelEl.className = 'setting-label';
labelEl.textContent = label;
const input = document.createElement('input');
input.type = 'number';
input.className = 'setting-input';
input.value = value;
input.addEventListener('change', (e) => {
onChange(parseFloat(e.target.value));
});
row.appendChild(labelEl);
row.appendChild(input);
return row;
}
/**
* Update filter badge on button
*/
updateFilterBadge(controlData) {
const count = controlData.filters.length;
if (count > 0) {
controlData.filtersBtn.innerHTML = `${count}`;
controlData.filtersBtn.classList.add('has-filters');
} else {
controlData.filtersBtn.innerHTML = '◇';
controlData.filtersBtn.classList.remove('has-filters');
}
}
/**
* Create a color picker control
*/
createColor(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container control-color-container';
const mainRow = document.createElement('div');
mainRow.className = 'control-main-row';
const label = document.createElement('span');
label.className = 'control-label-inline';
label.textContent = config.property;
const input = document.createElement('input');
input.type = 'color';
input.className = 'control control-color';
input.id = `el${config.property}`;
input.value = instance[config.property] ?? config.defaultValue;
const listener = (event) => {
const newValue = event.target.value;
instance[config.property] = newValue;
if (config.callback) {
config.callback(instance, newValue);
}
};
input.addEventListener('input', listener);
mainRow.appendChild(label);
mainRow.appendChild(input);
wrapper.appendChild(mainRow);
container.appendChild(wrapper);
return {
element: input,
listener,
type: 'color',
config,
wrapper,
label
};
}
/**
* Create a checkbox control
*/
createCheckbox(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container control-checkbox-container';
const mainRow = document.createElement('div');
mainRow.className = 'control-main-row';
const label = document.createElement('span');
label.className = 'control-label-inline';
label.textContent = config.property;
const input = document.createElement('input');
input.type = 'checkbox';
input.className = 'control control-checkbox';
input.id = `el${config.property}`;
input.checked = instance[config.property] ?? config.defaultValue;
const listener = (event) => {
const newValue = event.target.checked;
instance[config.property] = newValue;
if (config.callback) {
config.callback(instance, newValue);
}
};
input.addEventListener('change', listener);
mainRow.appendChild(label);
mainRow.appendChild(input);
wrapper.appendChild(mainRow);
container.appendChild(wrapper);
return {
element: input,
listener,
type: 'checkbox',
config,
wrapper,
label
};
}
/**
* Create a dropdown select control
*/
createDropdown(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container';
const label = document.createElement('p');
label.className = 'control-label';
label.id = `elText${config.property}`;
label.textContent = `${config.property}: ${config.defaultValue}`;
const select = document.createElement('select');
select.className = 'control control-dropdown';
select.id = `el${config.property}`;
for (const option of config.options || []) {
const opt = document.createElement('option');
opt.value = option.value;
opt.textContent = option.label;
select.appendChild(opt);
}
select.value = instance[config.property] ?? config.defaultValue;
const listener = (event) => {
const newValue = event.target.value;
instance[config.property] = newValue;
label.textContent = `${config.property}: ${newValue}`;
if (config.callback) {
config.callback(instance, newValue);
}
};
select.addEventListener('change', listener);
wrapper.appendChild(label);
wrapper.appendChild(select);
container.appendChild(wrapper);
return {
element: select,
listener,
type: 'dropdown',
config,
wrapper,
label
};
}
/**
* Create a button control
*/
createButton(config, instance, container) {
const wrapper = document.createElement('div');
wrapper.className = 'control-container';
const button = document.createElement('button');
button.className = 'control control-button button-8';
button.textContent = config.label || config.property;
const listener = () => {
if (config.method && typeof instance[config.method] === 'function') {
instance[config.method]();
}
if (config.callback) {
config.callback(instance);
}
};
button.addEventListener('click', listener);
wrapper.appendChild(button);
container.appendChild(wrapper);
return {
element: button,
listener,
type: 'button',
config,
wrapper
};
}
/**
* Create a header/separator
*/
createHeader(config, container) {
const header = document.createElement('p');
header.className = 'header';
header.id = `elHeader${(config.text || '').replace(/\s+/g, '')}`;
header.textContent = config.text || '';
container.appendChild(header);
return {
element: header,
listener: null,
type: 'header',
config,
wrapper: header
};
}
/**
* Create filter controls UI with collapsible settings
*/
createFilterUI(filterInstance, filterDef, controlData, controlConfig) {
const filterDiv = document.createElement('div');
filterDiv.className = 'filter-item';
// Header row with type dropdown and remove button
const headerRow = document.createElement('div');
headerRow.className = 'filter-item-header';
// Filter type dropdown (allows changing filter type)
const typeSelect = document.createElement('select');
typeSelect.className = 'filter-type-dropdown';
const filters = this.controlManager.filterManager.getAvailableFilters();
for (const filter of filters) {
const opt = document.createElement('option');
opt.value = filter.id;
opt.textContent = filter.name;
if (filter.id === filterInstance.type) {
opt.selected = true;
}
typeSelect.appendChild(opt);
}
// Settings toggle button
const settingsToggle = document.createElement('button');
settingsToggle.className = 'filter-settings-toggle';
settingsToggle.innerHTML = '⚙';
settingsToggle.title = 'Filter settings';
// Remove button
const removeBtn = document.createElement('button');
removeBtn.className = 'filter-remove-btn';
removeBtn.textContent = '×';
removeBtn.title = 'Remove filter';
headerRow.appendChild(typeSelect);
headerRow.appendChild(settingsToggle);
headerRow.appendChild(removeBtn);
filterDiv.appendChild(headerRow);
// Settings panel (collapsible)
const settingsPanel = document.createElement('div');
settingsPanel.className = 'filter-settings-panel';
settingsPanel.style.display = 'none';
// Create controls for filter parameters
const paramInputs = {};
this.buildFilterParams(settingsPanel, filterInstance, filterDef, controlConfig, paramInputs);
filterDiv.appendChild(settingsPanel);
// Toggle settings
settingsToggle.addEventListener('click', () => {
const isOpen = settingsPanel.style.display !== 'none';
settingsPanel.style.display = isOpen ? 'none' : 'block';
settingsToggle.classList.toggle('active', !isOpen);
});
// Handle filter type change
typeSelect.addEventListener('change', () => {
const newType = typeSelect.value;
const newFilterDef = this.controlManager.filterManager.getFilter(newType);
// Update filter instance type
filterInstance.type = newType;
// Reset params to defaults for new type
filterInstance.params = {};
for (const ctrl of newFilterDef.controls) {
if (ctrl.name === 'min') {
filterInstance.params.min = controlConfig.min ?? 0;
} else if (ctrl.name === 'max') {
filterInstance.params.max = controlConfig.max ?? 100;
} else {
filterInstance.params[ctrl.name] = ctrl.defaultValue ?? 1;
}
}
// Rebuild params UI
settingsPanel.innerHTML = '';
this.buildFilterParams(settingsPanel, filterInstance, newFilterDef, controlConfig, paramInputs);
});
// Remove handler
removeBtn.addEventListener('click', () => {
this.controlManager.removeFilter(controlData, filterInstance);
this.updateFilterBadge(controlData);
});
filterInstance.element = filterDiv;
return filterDiv;
}
/**
* Build filter parameter controls
*/
buildFilterParams(container, filterInstance, filterDef, controlConfig, paramInputs) {
for (const paramDef of filterDef.controls) {
const paramRow = document.createElement('div');
paramRow.className = 'filter-param-row';
const paramLabel = document.createElement('span');
paramLabel.className = 'filter-param-label';
paramLabel.textContent = paramDef.label;
const paramValue = document.createElement('span');
paramValue.className = 'filter-param-value';
paramValue.textContent = filterInstance.params[paramDef.name];
const paramInput = document.createElement('input');
paramInput.type = 'range';
paramInput.className = 'filter-param-slider';
// Determine bounds
if (paramDef.name === 'min' || paramDef.name === 'max') {
// Use much wider bounds for filter min/max - can go beyond control range
paramInput.min = (controlConfig.min ?? 0) - Math.abs(controlConfig.max - controlConfig.min);
paramInput.max = (controlConfig.max ?? 100) + Math.abs(controlConfig.max - controlConfig.min);
paramInput.value = filterInstance.params[paramDef.name];
} else {
paramInput.min = paramDef.min ?? 0.1;
paramInput.max = paramDef.max ?? 10;
paramInput.step = paramDef.step ?? 0.1;
paramInput.value = filterInstance.params[paramDef.name];
}
// Settings button for this parameter (to adjust its min/max)
const paramSettingsBtn = document.createElement('button');
paramSettingsBtn.className = 'param-settings-btn';
paramSettingsBtn.innerHTML = '⋯';
paramSettingsBtn.title = 'Adjust range';
// Mini settings panel for param bounds
const paramBoundsPanel = document.createElement('div');
paramBoundsPanel.className = 'param-bounds-panel';
paramBoundsPanel.style.display = 'none';
const boundsRow = document.createElement('div');
boundsRow.className = 'param-bounds-row';
const minInput = document.createElement('input');
minInput.type = 'number';
minInput.className = 'param-bound-input';
minInput.value = paramInput.min;
minInput.placeholder = 'min';
const maxInput = document.createElement('input');
maxInput.type = 'number';
maxInput.className = 'param-bound-input';
maxInput.value = paramInput.max;
maxInput.placeholder = 'max';
boundsRow.appendChild(minInput);
boundsRow.appendChild(maxInput);
paramBoundsPanel.appendChild(boundsRow);
minInput.addEventListener('change', () => {
paramInput.min = parseFloat(minInput.value);
});
maxInput.addEventListener('change', () => {
paramInput.max = parseFloat(maxInput.value);
});
paramSettingsBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isOpen = paramBoundsPanel.style.display !== 'none';
paramBoundsPanel.style.display = isOpen ? 'none' : 'flex';
});
paramInput.addEventListener('input', (e) => {
const value = parseFloat(e.target.value);
filterInstance.params[paramDef.name] = value;
paramValue.textContent = Math.round(value * 100) / 100;
});
const topRow = document.createElement('div');
topRow.className = 'filter-param-top-row';
topRow.appendChild(paramLabel);
topRow.appendChild(paramValue);
topRow.appendChild(paramSettingsBtn);
paramRow.appendChild(topRow);
paramRow.appendChild(paramInput);
paramRow.appendChild(paramBoundsPanel);
container.appendChild(paramRow);
paramInputs[paramDef.name] = paramInput;
}
}
}

View File

@@ -0,0 +1,116 @@
/**
* ControlManager - Manages UI controls for shapes
*
* Handles creation, updates, and cleanup of control panels
* Ready for Phase 2 multi-shape support (panels per shape)
*/
class ControlManager {
constructor(containerId) {
this.container = document.getElementById(containerId);
this.filterManager = filterManager; // Use global FilterManager
this.factory = new ControlFactory(this);
// Track all active controls (for future multi-shape, keyed by shape id)
this.activeControls = new Map();
}
/**
* Add a filter to a control
*/
addFilter(controlData, controlConfig, filterType) {
const filterInstance = this.filterManager.createFilterInstance(filterType, controlConfig);
const filterDef = this.filterManager.getFilter(filterType);
if (!controlData.filters) {
controlData.filters = [];
}
// Create filter UI
const filterUI = this.factory.createFilterUI(
filterInstance,
filterDef,
controlData,
controlConfig
);
controlData.filtersContainer.appendChild(filterUI);
controlData.filters.push(filterInstance);
}
/**
* Remove a filter from a control
*/
removeFilter(controlData, filterInstance) {
const index = controlData.filters.indexOf(filterInstance);
if (index > -1) {
controlData.filters.splice(index, 1);
if (filterInstance.element && filterInstance.element.parentNode) {
filterInstance.element.parentNode.removeChild(filterInstance.element);
}
}
}
/**
* Add a control for a shape
* @param {object} config - Control configuration
* @param {BaseShape} instance - Shape instance
* @returns {object} Control data
*/
addControl(config, instance) {
return this.factory.create(config, instance, this.container);
}
/**
* Remove a control
* @param {object} controlData - Control data from addControl
*/
removeControl(controlData) {
if (!controlData) return;
// Remove event listeners
if (controlData.element && controlData.listener) {
const eventType = controlData.type === 'checkbox' ? 'change' :
controlData.type === 'dropdown' ? 'change' : 'input';
controlData.element.removeEventListener(eventType, controlData.listener);
}
// Remove filters
if (controlData.filters) {
for (const filter of controlData.filters) {
if (filter.element && filter.element.parentNode) {
filter.element.parentNode.removeChild(filter.element);
}
}
}
// Remove wrapper element
if (controlData.wrapper && controlData.wrapper.parentNode) {
controlData.wrapper.parentNode.removeChild(controlData.wrapper);
}
}
/**
* Clear all controls
*/
clearAll() {
while (this.container.firstChild) {
this.container.removeChild(this.container.firstChild);
}
this.activeControls.clear();
}
/**
* Update a control's displayed value programmatically
*/
updateControlValue(property, value) {
const element = document.getElementById(`el${property}`);
const label = document.getElementById(`elText${property}`);
if (element) {
element.value = value;
}
if (label) {
label.textContent = Math.round(value * 100) / 100;
}
}
}

View File

@@ -0,0 +1,195 @@
/**
* FilterManager - Manages filter plugins and computes filtered values
*
* Filters modify control values over time (e.g., oscillate a slider)
*/
class FilterManager {
constructor() {
this.filterTypes = new Map();
this.registerBuiltinFilters();
}
/**
* Register built-in filter types
*/
registerBuiltinFilters() {
// Sine wave filter
this.registerFilter('sin', {
name: 'Sine Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const halfRange = (max - min) / 2;
return min + halfRange + Math.sin(rate * elapsed * 2) * halfRange;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Triangle wave filter
this.registerFilter('triangle', {
name: 'Triangle Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const period = (2 * Math.PI) / rate;
const t = (elapsed % period) / period;
const triangleValue = t < 0.5 ? t * 2 : 2 - t * 2;
return min + (max - min) * triangleValue;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Sawtooth wave filter
this.registerFilter('sawtooth', {
name: 'Sawtooth Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const period = (2 * Math.PI) / rate;
const t = (elapsed % period) / period;
return min + (max - min) * t;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Square wave filter
this.registerFilter('square', {
name: 'Square Wave',
compute: (params, elapsed) => {
const { min, max, rate } = params;
const sinValue = Math.sin(rate * elapsed * 2);
return sinValue >= 0 ? max : min;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'rate', type: 'range', label: 'Rate', min: 0.1, max: 10, defaultValue: 1 }
]
});
// Random/noise filter
this.registerFilter('noise', {
name: 'Noise',
compute: (params, elapsed) => {
const { min, max, smoothness } = params;
// Simple smooth random using sin of elapsed with random-ish multiplier
const noise = Math.sin(elapsed * 17.3) * Math.cos(elapsed * 31.7) * 0.5 + 0.5;
return min + (max - min) * noise;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'smoothness', type: 'range', label: 'Smoothness', min: 1, max: 100, defaultValue: 50 }
]
});
// Linear interpolation (ping-pong)
this.registerFilter('linear', {
name: 'Linear (Ping-Pong)',
compute: (params, elapsed) => {
const { min, max, duration } = params;
const cycleTime = elapsed % (duration * 2);
const t = cycleTime < duration
? cycleTime / duration
: 1 - (cycleTime - duration) / duration;
return min + (max - min) * t;
},
controls: [
{ name: 'min', type: 'range', label: 'Min' },
{ name: 'max', type: 'range', label: 'Max' },
{ name: 'duration', type: 'range', label: 'Duration (s)', min: 0.5, max: 30, defaultValue: 5 }
]
});
}
/**
* Register a custom filter type
* @param {string} id - Unique filter identifier
* @param {object} filterDef - Filter definition with name, compute, controls
*/
registerFilter(id, filterDef) {
this.filterTypes.set(id, filterDef);
}
/**
* Get all available filter types
* @returns {Array} Array of {id, name} objects
*/
getAvailableFilters() {
const filters = [];
for (const [id, def] of this.filterTypes) {
filters.push({ id, name: def.name });
}
return filters;
}
/**
* Get filter definition by id
* @param {string} id - Filter type id
* @returns {object|null} Filter definition
*/
getFilter(id) {
return this.filterTypes.get(id) || null;
}
/**
* Compute the combined value from multiple filters
* @param {Array} filters - Array of active filter instances
* @param {number} elapsed - Elapsed time in seconds
* @returns {number|null} Computed value or null if no filters
*/
computeFilteredValue(filters, elapsed) {
if (!filters || filters.length === 0) return null;
let totalValue = 0;
for (const filter of filters) {
const filterDef = this.getFilter(filter.type);
if (filterDef) {
totalValue += filterDef.compute(filter.params, elapsed);
}
}
return totalValue;
}
/**
* Create a filter instance with default params
* @param {string} type - Filter type id
* @param {object} controlConfig - The control's config (for min/max defaults)
* @returns {object} Filter instance
*/
createFilterInstance(type, controlConfig) {
const filterDef = this.getFilter(type);
if (!filterDef) {
throw new Error(`Unknown filter type: ${type}`);
}
const params = {};
for (const ctrl of filterDef.controls) {
if (ctrl.name === 'min') {
params.min = controlConfig.min ?? 0;
} else if (ctrl.name === 'max') {
params.max = controlConfig.max ?? 100;
} else {
params[ctrl.name] = ctrl.defaultValue ?? 1;
}
}
return {
type,
params,
element: null // Will hold DOM reference for cleanup
};
}
}
// Global singleton
const filterManager = new FilterManager();

771
docs/js/controls/SceneUI.js Normal file
View File

@@ -0,0 +1,771 @@
/**
* SceneUI - Manages the UI for multi-shape scenes
*
* Creates collapsible panels for each shape with:
* - Layer controls (visibility, pause, reorder, remove)
* - Shape-specific controls
* - Add new shape interface
*/
class SceneUI {
constructor(scene, containerId) {
this.scene = scene;
this.container = document.getElementById(containerId);
this.layerPanels = new Map(); // layerId -> { panel, controlManager }
this.createSceneControls();
}
/**
* Create the main scene control area (add shape button, etc.)
*/
createSceneControls() {
// Scene header
const header = document.createElement('div');
header.className = 'scene-header';
header.innerHTML = `
<h3>Scene Layers</h3>
<div class="scene-header-buttons">
<button class="scene-btn" id="add-shape-btn" title="Add Shape">+ Add</button>
<button class="scene-btn" id="save-scene-btn" title="Save Scene">💾 Save</button>
<button class="scene-btn" id="load-scene-btn" title="Load Scene">📂 Load</button>
<button class="scene-btn" id="presets-btn" title="Presets">⭐ Presets</button>
</div>
`;
this.container.insertBefore(header, this.container.firstChild);
// Shape picker modal (hidden by default)
this.createShapePicker();
// Presets modal
this.createPresetsModal();
// Hidden file input for loading
const fileInput = document.createElement('input');
fileInput.type = 'file';
fileInput.id = 'scene-file-input';
fileInput.accept = '.json';
fileInput.style.display = 'none';
document.body.appendChild(fileInput);
// Add shape button handler
document.getElementById('add-shape-btn').addEventListener('click', () => {
this.showShapePicker();
});
// Save button handler
document.getElementById('save-scene-btn').addEventListener('click', () => {
this.saveScene();
});
// Load button handler
document.getElementById('load-scene-btn').addEventListener('click', () => {
document.getElementById('scene-file-input').click();
});
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
this.loadScene(e.target.files[0]);
e.target.value = ''; // Reset for same file
}
});
// Presets button handler
document.getElementById('presets-btn').addEventListener('click', () => {
this.showPresetsModal();
});
}
/**
* Save current scene to file
*/
saveScene() {
const data = this.scene.exportToJSON();
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `scene-${Date.now()}.json`;
a.click();
URL.revokeObjectURL(url);
}
/**
* Load scene from file
*/
loadScene(file) {
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
this.scene.importFromJSON(data, this);
} catch (err) {
alert('Failed to load scene: ' + err.message);
}
};
reader.readAsText(file);
}
/**
* Create presets modal
*/
createPresetsModal() {
const modal = document.createElement('div');
modal.className = 'presets-modal';
modal.id = 'presets-modal';
modal.style.display = 'none';
modal.innerHTML = `
<div class="presets-content">
<h3>Presets</h3>
<div class="presets-actions">
<button class="preset-save-current-btn">💾 Save Current as Preset</button>
</div>
<div class="presets-list" id="presets-list">
<p class="presets-empty">No presets saved yet</p>
</div>
<button class="button presets-cancel">Close</button>
</div>
`;
document.body.appendChild(modal);
// Load and display presets
this.refreshPresetsList();
// Event handlers
modal.querySelector('.presets-cancel').addEventListener('click', () => {
this.hidePresetsModal();
});
modal.querySelector('.preset-save-current-btn').addEventListener('click', () => {
this.saveCurrentAsPreset();
});
// Click outside to close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hidePresetsModal();
}
});
}
showPresetsModal() {
this.refreshPresetsList();
document.getElementById('presets-modal').style.display = 'flex';
}
hidePresetsModal() {
document.getElementById('presets-modal').style.display = 'none';
}
/**
* Get user presets from localStorage
*/
getPresets() {
try {
return JSON.parse(localStorage.getItem('animation-presets') || '[]');
} catch {
return [];
}
}
/**
* Get built-in presets
*/
getBuiltInPresets() {
return typeof BUILT_IN_PRESETS !== 'undefined' ? BUILT_IN_PRESETS : [];
}
/**
* Save presets to localStorage
*/
savePresets(presets) {
localStorage.setItem('animation-presets', JSON.stringify(presets));
}
/**
* Save current scene as a preset
*/
saveCurrentAsPreset() {
const name = prompt('Enter preset name:');
if (!name) return;
const presets = this.getPresets();
const data = this.scene.exportToJSON();
presets.push({
name: name,
date: new Date().toISOString(),
data: data
});
this.savePresets(presets);
this.refreshPresetsList();
}
/**
* Load a preset (built-in or user)
*/
loadPreset(index, isBuiltIn = false) {
const presets = isBuiltIn ? this.getBuiltInPresets() : this.getPresets();
if (index >= 0 && index < presets.length) {
this.scene.importFromJSON(presets[index].data, this);
this.hidePresetsModal();
}
}
/**
* Delete a user preset by index
*/
deletePreset(index) {
const presets = this.getPresets();
if (index >= 0 && index < presets.length) {
if (confirm(`Delete preset "${presets[index].name}"?`)) {
presets.splice(index, 1);
this.savePresets(presets);
this.refreshPresetsList();
}
}
}
/**
* Refresh the presets list UI
*/
refreshPresetsList() {
const container = document.getElementById('presets-list');
if (!container) return;
const builtInPresets = this.getBuiltInPresets();
const userPresets = this.getPresets();
let html = '';
// Built-in presets section
if (builtInPresets.length > 0) {
html += '<div class="presets-section-header">Built-in Presets</div>';
html += builtInPresets.map((preset, index) => `
<div class="preset-item preset-builtin" data-index="${index}" data-builtin="true">
<div class="preset-info">
<span class="preset-name">${preset.name}</span>
<span class="preset-description">${preset.description || `${preset.data.layers.length} layer(s)`}</span>
</div>
<div class="preset-actions">
<button class="preset-load-btn" data-index="${index}" data-builtin="true">Load</button>
</div>
</div>
`).join('');
}
// User presets section
html += '<div class="presets-section-header">My Presets</div>';
if (userPresets.length === 0) {
html += '<p class="presets-empty">No saved presets yet</p>';
} else {
html += userPresets.map((preset, index) => `
<div class="preset-item" data-index="${index}">
<div class="preset-info">
<span class="preset-name">${preset.name}</span>
<span class="preset-layers">${preset.data.layers.length} layer(s)</span>
</div>
<div class="preset-actions">
<button class="preset-load-btn" data-index="${index}">Load</button>
<button class="preset-delete-btn" data-index="${index}">🗑</button>
</div>
</div>
`).join('');
}
container.innerHTML = html;
// Add event listeners
container.querySelectorAll('.preset-load-btn').forEach(btn => {
btn.addEventListener('click', () => {
const isBuiltIn = btn.dataset.builtin === 'true';
this.loadPreset(parseInt(btn.dataset.index), isBuiltIn);
});
});
container.querySelectorAll('.preset-delete-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.deletePreset(parseInt(btn.dataset.index));
});
});
}
/**
* Create the shape picker modal with tabs
*/
createShapePicker() {
const modal = document.createElement('div');
modal.className = 'shape-picker-modal';
modal.id = 'shape-picker-modal';
modal.style.display = 'none';
const shapes = shapeRegistry.getAvailableNames();
const shapesHtml = shapes.map(name =>
`<button class="shape-picker-btn" data-shape="${name}">${name}</button>`
).join('');
const builtInPresets = this.getBuiltInPresets();
const builtInHtml = builtInPresets.map((preset, i) =>
`<button class="shape-picker-btn preset-btn" data-preset-index="${i}" data-builtin="true">
<span class="preset-btn-name">${preset.name}</span>
<span class="preset-btn-info">${preset.data.layers.length} layer(s)</span>
</button>`
).join('');
modal.innerHTML = `
<div class="shape-picker-container">
<h3>Add Layer</h3>
<div class="shape-picker-tabs">
<button class="shape-picker-tab active" data-tab="shapes">Shapes</button>
<button class="shape-picker-tab" data-tab="presets">Presets</button>
</div>
<div class="shape-picker-content">
<div class="shape-picker-panel active" id="panel-shapes">
<div class="shape-picker-buttons">
${shapesHtml}
</div>
</div>
<div class="shape-picker-panel" id="panel-presets">
<div class="shape-picker-section">
<div class="shape-picker-section-title">Built-in</div>
<div class="shape-picker-buttons">
${builtInHtml}
</div>
</div>
<div class="shape-picker-section">
<div class="shape-picker-section-title">My Presets</div>
<div class="shape-picker-buttons" id="picker-user-presets">
<p class="presets-empty">No saved presets</p>
</div>
</div>
</div>
</div>
<button class="shape-picker-cancel">Cancel</button>
</div>
`;
document.body.appendChild(modal);
// Tab switching
modal.querySelectorAll('.shape-picker-tab').forEach(tab => {
tab.addEventListener('click', () => {
modal.querySelectorAll('.shape-picker-tab').forEach(t => t.classList.remove('active'));
modal.querySelectorAll('.shape-picker-panel').forEach(p => p.classList.remove('active'));
tab.classList.add('active');
document.getElementById(`panel-${tab.dataset.tab}`).classList.add('active');
});
});
// Cancel button
modal.querySelector('.shape-picker-cancel').addEventListener('click', () => {
this.hideShapePicker();
});
// Shape buttons
modal.querySelectorAll('.shape-picker-btn[data-shape]').forEach(btn => {
btn.addEventListener('click', () => {
this.addShapeToScene(btn.dataset.shape);
this.hideShapePicker();
});
});
// Preset buttons (add as layers, don't replace)
modal.querySelectorAll('.preset-btn[data-builtin="true"]').forEach(btn => {
btn.addEventListener('click', () => {
this.addPresetAsLayers(parseInt(btn.dataset.presetIndex), true);
this.hideShapePicker();
});
});
// Click outside to close
modal.addEventListener('click', (e) => {
if (e.target === modal) {
this.hideShapePicker();
}
});
}
/**
* Refresh user presets in the picker
*/
refreshPickerUserPresets() {
const container = document.getElementById('picker-user-presets');
if (!container) return;
const userPresets = this.getPresets();
if (userPresets.length === 0) {
container.innerHTML = '<p class="presets-empty">No saved presets</p>';
return;
}
container.innerHTML = userPresets.map((preset, i) =>
`<button class="shape-picker-btn preset-btn" data-preset-index="${i}" data-builtin="false">
<span class="preset-btn-name">${preset.name}</span>
<span class="preset-btn-info">${preset.data.layers.length} layer(s)</span>
</button>`
).join('');
container.querySelectorAll('.preset-btn').forEach(btn => {
btn.addEventListener('click', () => {
this.addPresetAsLayers(parseInt(btn.dataset.presetIndex), false);
this.hideShapePicker();
});
});
}
/**
* Add preset layers to scene (without clearing existing)
*/
addPresetAsLayers(index, isBuiltIn) {
const presets = isBuiltIn ? this.getBuiltInPresets() : this.getPresets();
if (index < 0 || index >= presets.length) return;
const preset = presets[index];
for (const layerData of preset.data.layers) {
try {
if (!shapeRegistry.get(layerData.name)) {
console.warn(`Shape "${layerData.name}" not found, skipping`);
continue;
}
const layerId = this.scene.addShape(layerData.name, layerData.values);
const layer = this.scene.getLayer(layerId);
if (!layer || !layer.shape) continue;
layer.visible = layerData.visible !== false;
layer.paused = layerData.paused || false;
layer.collapsed = layerData.collapsed !== false;
// Create panel with filters and bounds
this.createLayerPanel(layer, layerData.filters, layerData.controlBounds);
} catch (e) {
console.warn(`Failed to add layer "${layerData.name}":`, e.message);
}
}
}
showShapePicker() {
this.refreshPickerUserPresets();
document.getElementById('shape-picker-modal').style.display = 'flex';
}
hideShapePicker() {
document.getElementById('shape-picker-modal').style.display = 'none';
}
/**
* Add a shape to the scene and create its UI panel
* @param {string} shapeName
*/
addShapeToScene(shapeName) {
const layerId = this.scene.addShape(shapeName);
const layer = this.scene.getLayer(layerId);
// Create the layer panel UI first (it will contain the controls)
this.createLayerPanel(layer);
}
/**
* Create a collapsible panel for a layer
* @param {object|number} layerOrId - Layer object or layer ID
* @param {object} savedFilters - Optional saved filter settings to restore
* @param {object} savedBounds - Optional saved control bounds to restore
*/
createLayerPanel(layerOrId, savedFilters = null, savedBounds = null) {
// Handle both layer object and layer ID
const layer = typeof layerOrId === 'number'
? this.scene.getLayer(layerOrId)
: layerOrId;
if (!layer || !layer.shape) {
console.warn('Cannot create panel: layer or shape is undefined');
return;
}
const panel = document.createElement('div');
panel.className = 'layer-panel';
panel.id = `layer-panel-${layer.id}`;
// Layer header with controls
const header = document.createElement('div');
header.className = 'layer-header';
header.innerHTML = `
<span class="layer-collapse-btn" data-layer="${layer.id}">▼</span>
<span class="layer-name">${layer.name}</span>
<div class="layer-controls">
<button class="layer-btn layer-visibility" data-layer="${layer.id}" title="Toggle visibility">👁</button>
<button class="layer-btn layer-pause" data-layer="${layer.id}" title="Pause/Play">⏸</button>
<button class="layer-btn layer-up" data-layer="${layer.id}" title="Move up">↑</button>
<button class="layer-btn layer-down" data-layer="${layer.id}" title="Move down">↓</button>
<button class="layer-btn layer-remove" data-layer="${layer.id}" title="Remove">✕</button>
</div>
`;
// Layer content (shape controls container)
const content = document.createElement('div');
content.className = 'layer-content';
content.id = `layer-content-${layer.id}`;
panel.appendChild(header);
panel.appendChild(content);
// Add to layers container
const shapesContainer = document.getElementById('layers-container');
if (shapesContainer) {
shapesContainer.appendChild(panel);
} else {
this.container.appendChild(panel);
}
// Create a control manager specifically for this layer's content
const layerControlManager = new ControlManager(content.id);
// Initialize the shape's controls into the layer content
layer.shape.initializeControls(layerControlManager);
// Restore saved control bounds if provided
if (savedBounds) {
this.applyBoundsToShape(layer.shape, savedBounds);
}
// Restore saved filters if provided
if (savedFilters) {
this.applyFiltersToShape(layer.shape, layerControlManager, savedFilters);
}
// Store both panel and control manager
this.layerPanels.set(layer.id, { panel, controlManager: layerControlManager });
// Attach event handlers
this.attachLayerHandlers(layer.id, panel);
// Apply collapsed state
if (layer.collapsed) {
this.updateLayerUI(layer.id);
}
}
/**
* Apply saved filters to a shape's controls
*/
applyFiltersToShape(shape, controlManager, savedFilters) {
if (!savedFilters || !shape.controls) return;
for (const control of shape.controls) {
// Property is stored in control.config.property
const property = control.config?.property;
if (!property || !savedFilters[property]) continue;
const filterDataArray = savedFilters[property];
const controlConfig = control.config || {};
for (const filterData of filterDataArray) {
// Create filter instance with saved params
const filterInstance = {
type: filterData.type,
params: { ...filterData.params }
};
const filterDef = controlManager.filterManager.getFilter(filterData.type);
if (!filterDef) continue;
// Create filter UI
const filterUI = controlManager.factory.createFilterUI(
filterInstance,
filterDef,
control,
controlConfig
);
if (control.filtersContainer) {
control.filtersContainer.appendChild(filterUI);
}
if (!control.filters) {
control.filters = [];
}
control.filters.push(filterInstance);
// Update filter badge
controlManager.factory.updateFilterBadge(control);
}
}
}
/**
* Apply saved control bounds to a shape's controls
*/
applyBoundsToShape(shape, savedBounds) {
if (!savedBounds || !shape.controls) return;
for (const control of shape.controls) {
const property = control.config?.property;
if (!property || !savedBounds[property]) continue;
const bounds = savedBounds[property];
// Update configState
if (control.configState) {
control.configState.min = bounds.min;
control.configState.max = bounds.max;
}
// Update the input element's min/max
if (control.element) {
control.element.min = bounds.min;
control.element.max = bounds.max;
}
// Update the settings panel inputs if they exist
if (control.settingsPanel) {
const inputs = control.settingsPanel.querySelectorAll('.setting-input');
if (inputs[0]) inputs[0].value = bounds.min;
if (inputs[1]) inputs[1].value = bounds.max;
}
}
}
/**
* Attach event handlers for layer controls
*/
attachLayerHandlers(layerId, panel) {
// Collapse toggle
panel.querySelector('.layer-collapse-btn').addEventListener('click', () => {
this.toggleCollapse(layerId);
});
// Visibility toggle
panel.querySelector('.layer-visibility').addEventListener('click', () => {
this.scene.toggleVisibility(layerId);
this.updateLayerUI(layerId);
});
// Pause toggle
panel.querySelector('.layer-pause').addEventListener('click', () => {
this.scene.togglePause(layerId);
this.updateLayerUI(layerId);
});
// Move up
panel.querySelector('.layer-up').addEventListener('click', () => {
this.scene.moveUp(layerId);
this.reorderPanels();
});
// Move down
panel.querySelector('.layer-down').addEventListener('click', () => {
this.scene.moveDown(layerId);
this.reorderPanels();
});
// Remove
panel.querySelector('.layer-remove').addEventListener('click', () => {
this.removeLayer(layerId);
});
}
/**
* Toggle collapse state for a layer panel
*/
toggleCollapse(layerId) {
const layer = this.scene.getLayer(layerId);
if (!layer) return;
layer.collapsed = !layer.collapsed;
this.updateLayerUI(layerId);
}
/**
* Update layer UI to reflect current state
*/
updateLayerUI(layerId) {
const layer = this.scene.getLayer(layerId);
const layerData = this.layerPanels.get(layerId);
if (!layer || !layerData) return;
const panel = layerData.panel;
// Update collapse
const collapseBtn = panel.querySelector('.layer-collapse-btn');
const content = panel.querySelector('.layer-content');
if (layer.collapsed) {
collapseBtn.textContent = '▶';
content.style.display = 'none';
} else {
collapseBtn.textContent = '▼';
content.style.display = 'block';
}
// Update visibility button
const visBtn = panel.querySelector('.layer-visibility');
visBtn.style.opacity = layer.visible ? '1' : '0.4';
// Update pause button
const pauseBtn = panel.querySelector('.layer-pause');
pauseBtn.textContent = layer.paused ? '▶' : '⏸';
}
/**
* Reorder panels to match scene layer order
*/
reorderPanels() {
const container = document.getElementById('layers-container') || this.container;
for (const layer of this.scene.getLayers()) {
const layerData = this.layerPanels.get(layer.id);
if (layerData && layerData.panel) {
container.appendChild(layerData.panel); // Moves to end, in order
}
}
}
/**
* Remove a layer and its panel
*/
removeLayer(layerId) {
const layerData = this.layerPanels.get(layerId);
// Clean up control manager
if (layerData && layerData.controlManager) {
layerData.controlManager.clearAll();
}
// Remove from scene
this.scene.removeShape(layerId);
// Remove panel from DOM
if (layerData && layerData.panel && layerData.panel.parentNode) {
layerData.panel.parentNode.removeChild(layerData.panel);
}
this.layerPanels.delete(layerId);
}
/**
* Clear all layer panels (for import)
*/
clearAllPanels() {
for (const [layerId, layerData] of this.layerPanels) {
if (layerData.controlManager) {
layerData.controlManager.clearAll();
}
if (layerData.panel && layerData.panel.parentNode) {
layerData.panel.parentNode.removeChild(layerData.panel);
}
}
this.layerPanels.clear();
}
/**
* Clear all layer panels and scene
*/
clearAll() {
this.clearAllPanels();
this.scene.clear();
}
}

View File

@@ -0,0 +1,163 @@
/**
* AnimationEngine - Manages the canvas, render loop, and timing
*/
class AnimationEngine {
constructor(canvasId) {
this.canvas = document.getElementById(canvasId);
this.ctx = this.canvas.getContext('2d');
// Sizing
this.resizeCanvas();
window.addEventListener('resize', () => this.resizeCanvas());
// Timing
this.targetFps = 60;
this.frameDuration = 1000 / this.targetFps;
this.lastTimestamp = 0;
this.elapsedTime = 0;
this.rotation = 0;
this.degPerSec = 10;
// State
this.paused = false;
this.isRunning = false;
// Scene reference (set via setScene)
this.scene = null;
}
/**
* Set the scene to render
* @param {Scene} scene - The scene instance
*/
setScene(scene) {
this.scene = scene;
}
/**
* Resize canvas to fill window
*/
resizeCanvas() {
this.canvas.width = window.innerWidth;
this.canvas.height = window.innerHeight;
this.centerX = this.canvas.width / 2;
this.centerY = this.canvas.height / 2;
// Update global references for backward compatibility
if (typeof centerX !== 'undefined') {
centerX = this.centerX;
centerY = this.centerY;
}
if (typeof ctx !== 'undefined') {
ctx = this.ctx;
}
}
/**
* Clear the canvas
*/
clear() {
this.ctx.fillStyle = 'black';
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);
}
/**
* Main render loop
*/
render(timestamp) {
if (!this.isRunning) return;
if (!this.lastTimestamp) {
this.lastTimestamp = timestamp;
}
const deltaTime = timestamp - this.lastTimestamp;
this.lastTimestamp = timestamp;
let adjustedDeltaTime = 0;
if (!this.paused) {
this.rotation += this.degPerSec / this.targetFps;
this.elapsedTime += deltaTime;
adjustedDeltaTime = deltaTime / 100;
}
const adjustedElapsed = this.elapsedTime / 1000;
// Clear and draw
this.clear();
if (this.scene) {
this.scene.draw(adjustedElapsed, adjustedDeltaTime);
}
requestAnimationFrame((ts) => this.render(ts));
}
/**
* Start the animation loop
*/
start() {
if (this.isRunning) return;
this.isRunning = true;
this.lastTimestamp = 0;
requestAnimationFrame((ts) => this.render(ts));
}
/**
* Stop the animation loop
*/
stop() {
this.isRunning = false;
}
/**
* Toggle pause state
*/
togglePause() {
this.paused = !this.paused;
return this.paused;
}
/**
* Reset rotation/timing
*/
reset() {
this.rotation = 0;
this.elapsedTime = 0;
}
/**
* Step forward one frame
*/
stepForward() {
this.rotation += this.degPerSec / this.targetFps;
}
/**
* Step backward one frame
*/
stepBackward() {
this.rotation -= this.degPerSec / this.targetFps;
}
/**
* Set degrees per second
*/
setSpeed(degPerSec) {
this.degPerSec = parseFloat(degPerSec);
}
/**
* Get canvas context (for shapes that need direct access)
*/
getContext() {
return this.ctx;
}
/**
* Get center coordinates
*/
getCenter() {
return { x: this.centerX, y: this.centerY };
}
}

124
docs/js/core/BaseShape.js Normal file
View File

@@ -0,0 +1,124 @@
/**
* BaseShape - Base class for all animated shapes
*
* To create a new shape:
* 1. Create a new file in /js/shapes/
* 2. Extend BaseShape
* 3. Define static `config` array with control definitions
* 4. Implement constructor with matching parameters
* 5. Implement `draw(elapsed, deltaTime)` method
* 6. Register with shapeRegistry at the end of the file
*
* Example:
* ```
* class MyShape extends BaseShape {
* static config = [
* { type: 'range', property: 'size', min: 10, max: 500, defaultValue: 100 },
* { type: 'color', property: 'color', defaultValue: '#ff0000' }
* ];
*
* constructor(size, color) {
* super();
* this.size = size;
* this.color = color;
* }
*
* draw(elapsed, deltaTime) {
* this.updateFilters(elapsed);
* // Drawing code using this.size and this.color
* }
* }
* shapeRegistry.register('MyShape', MyShape);
* ```
*
* Control types:
* - range: { type, property, min, max, defaultValue, callback? }
* - color: { type, property, defaultValue }
* - checkbox: { type, property, defaultValue }
* - dropdown: { type, property, defaultValue, options: [{value, label}] }
* - button: { type, label, method }
* - header: { type, text }
*/
class BaseShape {
constructor() {
this.controls = [];
this.controlManager = null;
this.speedMultiplier = 500;
}
/**
* Initialize controls using the ControlManager
* Called automatically when a shape is created
* @param {ControlManager} controlManager - The control manager instance
*/
initializeControls(controlManager) {
this.controlManager = controlManager;
// Get config from static property
const config = this.constructor.config || [];
for (const item of config) {
const controlData = controlManager.addControl(item, this);
if (controlData) {
this.controls.push(controlData);
}
}
// Add speed multiplier control (common to all shapes)
const speedControl = controlManager.addControl({
type: 'range',
min: 1,
max: 1000,
defaultValue: this.speedMultiplier,
property: 'speedMultiplier'
}, this);
if (speedControl) {
this.controls.push(speedControl);
}
}
/**
* Clean up controls when shape is removed
*/
remove() {
if (this.controlManager) {
for (const control of this.controls) {
this.controlManager.removeControl(control);
}
}
this.controls = [];
}
/**
* Update any active filters on controls
* Call this at the start of your draw() method
* @param {number} elapsed - Total elapsed time in seconds
*/
updateFilters(elapsed) {
if (!this.controlManager) return;
for (const control of this.controls) {
if (control.filters && control.filters.length > 0) {
const newValue = this.controlManager.filterManager.computeFilteredValue(
control.filters,
elapsed
);
if (newValue !== null && control.element) {
control.element.value = newValue;
const event = new Event('input', { bubbles: true });
control.element.dispatchEvent(event);
}
}
}
}
/**
* Main draw method - override in subclasses
* @param {number} elapsed - Total elapsed time in seconds
* @param {number} deltaTime - Time since last frame
*/
draw(elapsed, deltaTime) {
throw new Error('draw() method not implemented');
}
}

267
docs/js/core/Scene.js Normal file
View File

@@ -0,0 +1,267 @@
/**
* Scene - Manages multiple shapes on a single canvas
*
* Features:
* - Multiple shapes rendered in layer order
* - Per-shape visibility and pause controls
* - Collapsible control panels per shape
* - Add/remove shapes dynamically
*/
class Scene {
constructor() {
this.layers = []; // Array of { id, shape, visible, paused, collapsed }
this.nextLayerId = 1;
}
/**
* Add a shape to the scene
* @param {string} shapeName - Name of the shape class
* @param {object} initialValues - Initial property values
* @returns {number} Layer ID
*/
addShape(shapeName, initialValues = {}) {
const shape = shapeRegistry.createInstance(shapeName, initialValues);
if (!shape) {
throw new Error(`Failed to create shape: ${shapeName}`);
}
const layer = {
id: this.nextLayerId++,
name: shapeName,
shape: shape,
visible: true,
paused: false,
collapsed: false
};
this.layers.push(layer);
return layer.id;
}
/**
* Remove a shape from the scene
* @param {number} layerId - Layer ID to remove
*/
removeShape(layerId) {
const index = this.layers.findIndex(l => l.id === layerId);
if (index !== -1) {
const layer = this.layers[index];
// Clean up controls
if (layer.shape.remove) {
layer.shape.remove();
}
this.layers.splice(index, 1);
}
}
/**
* Get a layer by ID
* @param {number} layerId
* @returns {object|null}
*/
getLayer(layerId) {
return this.layers.find(l => l.id === layerId) || null;
}
/**
* Toggle layer visibility
* @param {number} layerId
*/
toggleVisibility(layerId) {
const layer = this.getLayer(layerId);
if (layer) {
layer.visible = !layer.visible;
}
}
/**
* Toggle layer pause state
* @param {number} layerId
*/
togglePause(layerId) {
const layer = this.getLayer(layerId);
if (layer) {
layer.paused = !layer.paused;
}
}
/**
* Toggle layer control panel collapsed state
* @param {number} layerId
*/
toggleCollapsed(layerId) {
const layer = this.getLayer(layerId);
if (layer) {
layer.collapsed = !layer.collapsed;
}
}
/**
* Move a layer up in the render order
* @param {number} layerId
*/
moveUp(layerId) {
const index = this.layers.findIndex(l => l.id === layerId);
if (index > 0) {
[this.layers[index - 1], this.layers[index]] = [this.layers[index], this.layers[index - 1]];
}
}
/**
* Move a layer down in the render order
* @param {number} layerId
*/
moveDown(layerId) {
const index = this.layers.findIndex(l => l.id === layerId);
if (index < this.layers.length - 1) {
[this.layers[index], this.layers[index + 1]] = [this.layers[index + 1], this.layers[index]];
}
}
/**
* Draw all visible shapes
* @param {number} elapsed - Total elapsed time
* @param {number} deltaTime - Time since last frame
*/
draw(elapsed, deltaTime) {
for (const layer of this.layers) {
if (layer.visible && !layer.paused) {
layer.shape.draw(elapsed, deltaTime);
}
}
}
/**
* Get all layers
* @returns {array}
*/
getLayers() {
return this.layers;
}
/**
* Clear all layers
*/
clear() {
for (const layer of this.layers) {
if (layer.shape.remove) {
layer.shape.remove();
}
}
this.layers = [];
}
/**
* Export scene to JSON
* @returns {object} Scene data
*/
exportToJSON() {
return {
version: 1,
layers: this.layers.map(layer => {
const shape = layer.shape;
const ShapeClass = shape.constructor;
const config = ShapeClass.config || [];
// Extract current values from shape
const values = {};
config.forEach(item => {
if (item.property && shape.hasOwnProperty(item.property)) {
values[item.property] = shape[item.property];
}
});
// Also save speedMultiplier
values.speedMultiplier = shape.speedMultiplier;
// Extract filter settings and control bounds from shape.controls
const filters = {};
const controlBounds = {};
if (shape.controls) {
for (const control of shape.controls) {
const property = control.config?.property;
if (!property) continue;
// Save filters
if (control.filters && control.filters.length > 0) {
filters[property] = control.filters.map(f => ({
type: f.type,
params: { ...f.params }
}));
}
// Save custom min/max bounds if different from original
if (control.configState) {
const { min, max, originalMin, originalMax } = control.configState;
if (min !== originalMin || max !== originalMax) {
controlBounds[property] = { min, max };
}
}
}
}
return {
name: layer.name,
visible: layer.visible,
paused: layer.paused,
collapsed: layer.collapsed,
values: values,
filters: filters,
controlBounds: controlBounds
};
})
};
}
/**
* Import scene from JSON
* @param {object} data - Scene data
* @param {SceneUI} sceneUI - UI instance to rebuild panels
*/
importFromJSON(data, sceneUI) {
// Clear existing
this.clear();
if (sceneUI) {
sceneUI.clearAllPanels();
}
// Validate version
if (!data.version || !data.layers) {
throw new Error('Invalid scene data');
}
// Recreate layers
for (const layerData of data.layers) {
try {
// Check if shape type exists
if (!shapeRegistry.get(layerData.name)) {
console.warn(`Shape "${layerData.name}" not found, skipping`);
continue;
}
const layerId = this.addShape(layerData.name, layerData.values);
const layer = this.getLayer(layerId);
if (!layer || !layer.shape) {
console.warn(`Failed to create layer for "${layerData.name}"`);
continue;
}
layer.visible = layerData.visible !== false;
layer.paused = layerData.paused || false;
layer.collapsed = layerData.collapsed || false;
// Create UI panel (this also creates controls, applies filters and bounds)
if (sceneUI) {
sceneUI.createLayerPanel(layer, layerData.filters, layerData.controlBounds);
}
} catch (e) {
console.warn(`Failed to load shape "${layerData.name}":`, e.message);
}
}
}
}
// Global scene instance
const scene = new Scene();

View File

@@ -0,0 +1,89 @@
/**
* ShapeRegistry - Automatically registers and manages available shape classes
* Shapes register themselves when loaded, no manual map needed
*/
class ShapeRegistry {
constructor() {
this.shapes = new Map();
}
/**
* Register a shape class
* @param {string} name - The name of the shape
* @param {class} shapeClass - The shape class constructor
*/
register(name, shapeClass) {
if (this.shapes.has(name)) {
console.warn(`Shape "${name}" is already registered. Overwriting.`);
}
this.shapes.set(name, shapeClass);
}
/**
* Get a shape class by name
* @param {string} name - The name of the shape
* @returns {class|null} The shape class or null if not found
*/
get(name) {
return this.shapes.get(name) || null;
}
/**
* Create an instance of a shape
* @param {string} name - The name of the shape
* @param {object} initialValues - Initial property values (overrides defaults)
* @returns {BaseShape} The shape instance
*/
createInstance(name, initialValues = {}) {
const ShapeClass = this.get(name);
if (!ShapeClass) {
throw new Error(`Unknown shape: "${name}". Available shapes: ${this.getAvailableNames().join(', ')}`);
}
// Get the static config to determine constructor argument order
const config = ShapeClass.config || [];
// Build positional arguments array from config defaults + initialValues overrides
const args = config
.filter(item => item.property) // Only items with properties
.map(item => {
// Use provided value or fall back to default
return initialValues.hasOwnProperty(item.property)
? initialValues[item.property]
: item.defaultValue;
});
return new ShapeClass(...args);
}
/**
* Get all registered shape names (alphabetically sorted)
* @returns {string[]} Array of shape names
*/
getAvailableNames() {
return Array.from(this.shapes.keys()).sort((a, b) => a.localeCompare(b));
}
/**
* Get the config definition for a shape
* @param {string} name - The name of the shape
* @returns {array|null} The config array or null
*/
getConfig(name) {
const ShapeClass = this.get(name);
if (!ShapeClass) return null;
return ShapeClass.config || [];
}
/**
* Check if a shape is registered
* @param {string} name - The name of the shape
* @returns {boolean}
*/
has(name) {
return this.shapes.has(name);
}
}
// Global singleton instance
const shapeRegistry = new ShapeRegistry();

View File

@@ -1,617 +0,0 @@
async function fetchConfig(className) {
// const config = await $.getJSON("config.json");
const config = {
PolyTwistColourWidth: [
{ type: "range", min: 3, max: 10, defaultValue: 5, property: "sides" },
{ type: "range", min: 400, max: 2000, defaultValue: 400, property: "width" },
{ type: "range", min: 2, max: 5, defaultValue: 5, property: "line_width" },
{ type: "range", min: 1, max: 100, defaultValue: 50, property: "depth" },
{ type: "range", min: -180, max: 180, defaultValue: -90, property: "rotation", },
{ type: "range", min: 1, max: 500, defaultValue: 100, property: "speedMultiplier", },
{ type: "color", defaultValue: "#4287f5", property: "colour1" },
{ type: "color", defaultValue: "#42f57b", property: "colour2" },
],
FloralPhyllo: [
{ type: "range", min: 1, max: 600, defaultValue: 300, property: "width" },
{ type: "range", min: 1, max: 300, defaultValue: 150, property: "depth" },
{ type: "range", min: 0, max: 3141, defaultValue: 0, property: "start" },
{ type: "color", defaultValue: "#4287f5", property: "colour1" },
{ type: "color", defaultValue: "#FC0362", property: "colour2" },
],
Spiral1: [
{ type: "range", min: 1, max: 50, defaultValue: 20, property: "sides" },
{ type: "range", min: 1, max: 600, defaultValue: 240, property: "width" },
{ type: "color", defaultValue: "#4287f5", property: "colour" },
],
FloralAccident: [
{ type: "range", min: 1, max: 50, defaultValue: 20, property: "sides" },
{ type: "range", min: 1, max: 600, defaultValue: 240, property: "width" },
{ type: "color", defaultValue: "#4287f5", property: "colour" },
],
FloralPhyllo_Accident: [
{ type: "range", min: 1, max: 50, defaultValue: 20, property: "sides" },
{ type: "range", min: 1, max: 600, defaultValue: 240, property: "width" },
{ type: "color", defaultValue: "#2D81FC", property: "colour1" },
{ type: "color", defaultValue: "#FC0362", property: "colour2" },
],
Nodal_expanding: [
{ type: "range", min: 1, max: 100, defaultValue: 5, property: "expand" },
{ type: "range", min: 1, max: 1000, defaultValue: 150, property: "points" },
{ type: "range", min: 1, max: 360, defaultValue: 0, property: "start" },
{ type: "range", min: 1, max: 10, defaultValue: 6, property: "line_width" },
{ type: "color", defaultValue: "#2D81FC", property: "colour1" },
{ type: "color", defaultValue: "#FC0362", property: "colour2" },
{ type: "range", min: 0, max: 10, defaultValue: 5, property: "colour_change" },
],
Phyllotaxis: [
{ type: "range", min: 1, max: 40, defaultValue: 24, property: "width" },
{ type: "range", min: 1, max: 40, defaultValue: 10, property: "size" },
{ type: "range", min: 1, max: 40, defaultValue: 4, property: "sizeMin" },
{ type: "range", min: 0, max: 3141, defaultValue: 0, property: "start" },
{ type: "range", min: 1, max: 10000, defaultValue: 300, property: "nMax" },
{ type: "range", min: 0, max: 2, defaultValue: 0, property: "wave" },
{ type: "range", min: 1, max: 12, defaultValue: 2, property: "spiralProngs" },
{ type: "color", defaultValue: "#2D81FC", property: "colour1" },
{ type: "color", defaultValue: "#FC0362", property: "colour2" },
],
SquareTwist_angle: [
{ type: "range", min: 1, max: 800, defaultValue: 400, property: "width" },
{ type: "range", min: 1, max: 10, defaultValue: 1, property: "line_width" },
{ type: "color", defaultValue: "#2D81FC", property: "colour1" },
],
EyePrototype: [
{ type: "range", min: -400, max: 400, defaultValue: 0, property: "x" },
{ type: "range", min: -400, max: 400, defaultValue: 0, property: "y" },
{ type: "range", min: -180, max: 180, defaultValue: 0, property: "rotate" },
{ type: "range", min: 0, max: 1, defaultValue: 1, property: "flip" },
{ type: "range", min: 1, max: 800, defaultValue: 400, property: "width" },
{ type: "range", min: 1, max: 100, defaultValue: 5, property: "blink_speed" },
{ type: "range", min: 0, max: 1, defaultValue: 0, property: "draw_spiral" },
{ type: "range", min: 0, max: 1, defaultValue: 1, property: "spiral_full" },
{ type: "range", min: 0, max: 1, defaultValue: 0, property: "draw_pupil" },
{ type: "range", min: 0, max: 1, defaultValue: 0, property: "draw_expand" },
{ type: "range", min: 0, max: 1, defaultValue: 1, property: "draw_hypno" },
{ type: "range", min: 1, max: 10, defaultValue: 1, property: "line_width" },
{ type: "color", defaultValue: "#00fffb", property: "colourPupil" },
{ type: "color", defaultValue: "#ff0000", property: "colourSpiral" },
{ type: "color", defaultValue: "#00fffb", property: "colourExpand" },
{ type: "range", min: 0, max: 1, defaultValue: 1, property: "draw_eyelid" },
],
CircleExpand: [
{ type: "range", min: 1, max: 70, defaultValue: 21, property: "nCircles" },
{ type: "range", min: 50, max: 150, defaultValue: 150, property: "gap" },
{ type: "range", min: 0, max: 1, defaultValue: 1, property: "linear" },
{ type: "range", min: 0, max: 1, defaultValue: 1, property: "heart" },
{ type: "color", defaultValue: "#fc03cf", property: "colour1" },
{ type: "color", defaultValue: "#00fffb", property: "colour2" },
],
MaryFace: [
{ type: "range", min: -400, max: 400, defaultValue: -110, property: "x1" },
{ type: "range", min: -400, max: 400, defaultValue: -140, property: "y1" },
{ type: "range", min: -180, max: 180, defaultValue: 18, property: "rotate1" },
{ type: "range", min: 0, max: 400, defaultValue: 160, property: "width1" },
{ type: "range", min: -400, max: 400, defaultValue: 195, property: "x2" },
{ type: "range", min: -400, max: 400, defaultValue: -30, property: "y2" },
{ type: "range", min: -180, max: 180, defaultValue: 18, property: "rotate2" },
{ type: "range", min: 0, max: 400, defaultValue: 160, property: "width2" },
],
Countdown: [
{ type: "range", min: 8000, max: 2000000, defaultValue: 2000000, property: "milestone" },
],
NewWave: [
{ type: "range", min: 300, max: 1600, defaultValue: 342, property: "width" },
{ type: "range", min: 2, max: 40, defaultValue: 4, property: "sides" },
{ type: "range", min: 1, max: 100, defaultValue: 1, property: "step" },
{ type: "range", min: 1, max: 10, defaultValue: 4, property: "lineWidth" },
{ type: "range", min: 100, max: 1000, defaultValue: 100, property: "limiter" },
],
RaysInShape: [
{ type: "range", min: 50, max: 1000, defaultValue: 500, property: "rays", callback: (instance, newValue) => instance.setRays(newValue) },
{ type: "range", min: 1, max: 30, defaultValue: 2, property: "speed" },
{ type: "checkbox", defaultValue: true, property: "doesWave" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedVertRate" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedHorrRate" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedVert" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedHorr" },
{ type: "range", min: 10, max: 2000, defaultValue: 800, property: "boxSize" },
{ type: "range", min: 1, max: 80, defaultValue: 5, property: "trailLength" },
{ type: "range", min: 1, max: 500, defaultValue: 5, property: "lineWidth" },
{ type: "checkbox", defaultValue: false, property: "fade" },
{ type: "color", defaultValue: "#43dbad", property: "colourFree" },
{ type: "color", defaultValue: "#f05c79", property: "colourContained" },
{ type: "header", text: "--CollisionBox---" },
{ type: "checkbox", defaultValue: false, property: "boxVisible" },
// {
// type: "dropdown",
// property: "exampleDropdown",
// defaultValue: "",
// options: [
// { value: "", label: "None" },
// { value: "field_white", label: "Field Whtie" },
// ]
// },
// {
// type: "button",
// label: "Apply Background",
// method: "applyBackground"
// }
// {
// type: "range",
// min: 1,
// max: 10,
// defaultValue: 5,
// property: "magnitude",
// callback: (instance, newValue) => instance.setMagnitude(newValue)
// },
],
};
return config[className];
}
function addControl(item, instance) {
let parentDiv = document.getElementById("shape-controls");
let title = document.createElement("p");
title.innerText = item.property + ": " + item.defaultValue;
title.id = "elText" + item.property;
let control;
let eventListener = null;
if (item.type === "range") {
control = document.createElement("input");
control.type = "range";
control.id = "el" + item.property;
control.min = item.min;
control.max = item.max;
control.value = item.defaultValue;
eventListener = (event) => {
const newValue = parseInt(event.target.value, 10);
instance[item.property] = newValue;
title.innerText = item.property + ": " + newValue;
if (item.callback) {
item.callback(instance, newValue);
}
};
control.addEventListener("input", eventListener);
} else if (item.type === "button") {
control = document.createElement("button");
control.innerText = item.label;
control.addEventListener("click", () => {
instance[item.method]();
});
} else if (item.type === "dropdown") {
control = document.createElement("select");
item.options.forEach(option => {
let optionElement = document.createElement("option");
optionElement.value = option.value;
optionElement.innerText = option.label;
control.appendChild(optionElement);
});
control.value = item.defaultValue;
control.addEventListener("change", (event) => {
const newValue = event.target.value;
instance[item.property] = newValue;
title.innerText = item.property + ": " + newValue;
if (item.callback) {
item.callback(instance, newValue);
}
});
} else if (item.type === "header") {
control = document.createElement("p");
control.innerText = item.text;
control.className = "header";
control.id = "elHeader" + item.text.replace(/\s+/g, '');
// Headers are handled differently - add directly to parent
parentDiv.appendChild(control);
return { element: control, listener: eventListener };
}
else if (item.type === "color") {
control = document.createElement("input");
control.type = item.type;
control.value = item.defaultValue;
control.id = "el" + item.property;
eventListener = (event) => {
const newValue = event.target.value;
instance[item.property] = newValue;
title.innerText = item.property + ": " + newValue;
};
control.addEventListener("input", eventListener);
}
else if (item.type === "checkbox") {
control = document.createElement("input");
control.type = item.type;
control.checked = item.defaultValue;
instance[item.property] = item.defaultValue;
control.id = "el" + item.property;
control.addEventListener("change", (event) => {
const newValue = event.target.checked;
instance[item.property] = newValue;
title.innerText = item.property + ": " + newValue;
})
}
if (item.type != "header") {
control.className = "control";
}
// Create container div for the control
let containerDiv = document.createElement("div");
containerDiv.className = "control-container";
// Add title and control to container
if (item.type != "button") {
containerDiv.appendChild(title);
}
containerDiv.appendChild(control);
let filtersDiv = document.createElement("div");
// if (item.type === "range") {
// let addFilterButton = document.createElement("button");
// addFilterButton.innerText = "Add Filter";
// addFilterButton.className = "add-filter-button";
// addFilterButton.addEventListener("click", () => {
// const filterDiv = createFilter(item);
// filtersDiv.appendChild(filterDiv);
// });
// filtersDiv.appendChild(addFilterButton);
// }
// Add filters div at the bottom
filtersDiv.className = "control-filters";
filtersDiv.id = "filters-" + item.property;
containerDiv.appendChild(filtersDiv);
// Add the complete container to parent
parentDiv.appendChild(containerDiv);
return { element: control, listener: eventListener, filtersDiv: filtersDiv };
}
function createFilter(item) {
const filterDiv = document.createElement("div");
filterDiv.className = "filter-div";
filterDiv.innerText = "sin filter"; // Placeholder text
let minTitle = document.createElement("p");
minTitle.innerText = "Min:" + item.defaultValue;
filterDiv.appendChild(minTitle);
let sinMin = document.createElement("input");
sinMin.type = "range";
sinMin.id = "el-filter-" + item.property;
sinMin.min = -item.max;//item.min;
sinMin.max = item.max;
sinMin.value = item.defaultValue;
eventListener = (event) => {
const newValue = parseInt(event.target.value, 10);
// instance[item.property] = newValue;
minTitle.innerText = "Min: " + newValue;
if (item.callback) {
item.callback(instance, newValue);
}
};
sinMin.addEventListener("input", eventListener);
filterDiv.appendChild(sinMin);
let maxTitle = document.createElement("p");
maxTitle.innerText = "Max:" + item.defaultValue;
filterDiv.appendChild(maxTitle);
let sinMax = document.createElement("input");
sinMax.type = "range";
sinMax.id = "el-filter-" + item.property;
sinMax.min = item.min;
sinMax.max = item.max;
sinMax.value = item.defaultValue;
eventListener = (event) => {
const newValue = parseInt(event.target.value, 10);
// instance[item.property] = newValue;
maxTitle.innerText = "Max: " + newValue;
if (item.callback) {
item.callback(instance, newValue);
}
};
sinMax.addEventListener("input", eventListener);
filterDiv.appendChild(sinMax);
let rate = createFilterSlider("Rate", item, filterDiv);
return { filterDiv, min: sinMin, max: sinMax, rate: rate};
}
function createFilterSlider(name, item, filterDiv) {
let minTitle = document.createElement("p");
minTitle.innerText = name + ":" + item.defaultValue;
filterDiv.appendChild(minTitle);
let sinMin = document.createElement("input");
sinMin.type = "range";
sinMin.id = "el-filter-" + item.property;
sinMin.min = item.min;
sinMin.max = item.max;
sinMin.value = item.defaultValue;
eventListener = (event) => {
const newValue = parseInt(event.target.value, 10);
// instance[item.property] = newValue;
minTitle.innerText = name + ": " + newValue;
if (item.callback) {
item.callback(instance, newValue);
}
};
sinMin.addEventListener("input", eventListener);
filterDiv.appendChild(sinMin);
return sinMin;
}
function updateControlInput(value, controlName) {// Find and update the slider element
const elementSlider = document.querySelector('input[type="range"][id="el' + controlName + '"]');
if (elementSlider) {
// Update the slider value
elementSlider.value = value;
// Update the text display
const elementSliderText = document.getElementById(`elText${controlName}`);
if (elementSliderText) {
elementSliderText.innerText = `${controlName}: ${Math.round(value)}`;
}
}
}
function drawEyelid(width, x1, y1, colour) {
x1 -= centerX;
y1 -= centerY;
const angle = Math.atan2(y1, x1);
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
const x2 = cosAngle * width;
const y2 = sinAngle * width;
const x3Old = width / 2;
const y3Old = width / 2;
const x4Old = width / 2;
const y4Old = -width / 2;
const x3 = x3Old * cosAngle - y3Old * sinAngle;
const y3 = x3Old * sinAngle + y3Old * cosAngle;
const x4 = x4Old * cosAngle - y4Old * sinAngle;
const y4 = x4Old * sinAngle + y4Old * cosAngle;
x1 += centerX;
y1 += centerY;
const x2Final = x2 + x1;
const y2Final = y2 + y1;
const x3Final = x3 + x1;
const y3Final = y3 + y1;
const x4Final = x4 + x1;
const y4Final = y4 + y1;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x3Final, y3Final, x2Final, y2Final);
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x4Final, y4Final, x2Final, y2Final);
ctx.fillStyle = colour;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = "black";
ctx.stroke();
}
function drawEyelidAccident(x1, y1) {
let leafWidth = 120;
let leafHeight = 60;
x1 -= centerX;
y1 -= centerY;
let angle = Math.atan(y1 / x1);
// if(angle >=Math.PI){
// angle -=Math.PI
// console.log("greater called")
// }
angle = Math.abs(angle);
let x2Old = 0 + leafWidth;
let y2Old = 0;
let x3Old = 0 + leafWidth / 2;
let y3Old = 0 + leafHeight / 2;
let x4Old = 0 + leafWidth / 2;
let y4Old = 0 - leafHeight / 2;
let x2 = x2Old * Math.cos(angle) - y2Old * Math.sin(angle);
let y2 = x2Old * Math.sin(angle) + y2Old * Math.cos(angle);
let x3 = x3Old * Math.cos(angle) - y3Old * Math.sin(angle);
let y3 = x3Old * Math.sin(angle) + y3Old * Math.cos(angle);
let x4 = x4Old * Math.cos(angle) - y4Old * Math.sin(angle);
let y4 = x4Old * Math.sin(angle) + y4Old * Math.cos(angle);
let oldx1 = x1;
let oldy1 = y1;
x1 += centerX; // +x2/2
y1 += centerY; // +x2/2
x2 += x1;
y2 += y1;
x3 += x1;
y3 += y1;
x4 += x1;
y4 += y1;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x3, y3, x2, y2);
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x4, y4, x2, y2);
ctx.fillStyle = "black";
ctx.fill();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x3, y3, x2, y2);
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x4, y4, x2, y2);
ctx.strokeStyle = "orange";
ctx.stroke();
}
function DrawPolygon(sides, width, rotation, colour, line_width) {
ctx.beginPath();
ctx.moveTo(
centerX + width * Math.cos((rotation * Math.PI) / 180),
centerY + width * Math.sin((rotation * Math.PI) / 180)
);
for (var i = 1; i <= sides; i += 1) {
ctx.lineTo(
centerX +
width *
Math.cos((i * 2 * Math.PI) / sides + (rotation * Math.PI) / 180),
centerY +
width * Math.sin((i * 2 * Math.PI) / sides + (rotation * Math.PI) / 180)
);
}
ctx.strokeStyle = colour;
ctx.lineWidth = line_width;
ctx.stroke();
}
function rad(degrees) {
return (degrees * Math.PI) / 180;
}
function colourToText(colour) {
return "rgb(" + colour[0] + "," + colour[1] + "," + colour[2] + ")";
}
function waveNormal(x, max) {
let val = Math.sin((x / max) * Math.PI * 2 - max * (Math.PI / (max * 2))) / 2 + 0.5
return val
}
function hexToRgb(hex) {
var result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
function LerpHex(a, b, amount) {
var ah = parseInt(a.replace(/#/g, ""), 16),
ar = ah >> 16,
ag = (ah >> 8) & 0xff,
ab = ah & 0xff,
bh = parseInt(b.replace(/#/g, ""), 16),
br = bh >> 16,
bg = (bh >> 8) & 0xff,
bb = bh & 0xff,
rr = ar + amount * (br - ar),
rg = ag + amount * (bg - ag),
rb = ab + amount * (bb - ab);
return (
"#" + (((1 << 24) + (rr << 16) + (rg << 8) + rb) | 0).toString(16).slice(1)
);
}
function LerpRGB(a, b, t) {
if (t < 0) {
t *= -1;
}
var newColor = [0, 0, 0];
newColor[0] = a[0] + (b[0] - a[0]) * t;
newColor[1] = a[1] + (b[1] - a[1]) * t;
newColor[2] = a[2] + (b[2] - a[2]) * t;
return newColor;
}
function lerpRGB(a, b, t) {
const result = [0, 0, 0];
for (let i = 0; i < 3; i++) {
result[i] = (1 - t) * a[i] + t * b[i];
}
return result;
}
function drawCenter(width) {
console.log("center?")
ctx.strokeStyle = "pink";
ctx.lineWidth = 1
ctx.beginPath();
ctx.moveTo(centerX - width, centerY);
ctx.lineTo(centerX + width, centerY);
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(centerX, centerY - width);
ctx.lineTo(centerX, centerY + width);
ctx.closePath();
ctx.stroke();
}
function render_clear() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
function rotatePointTmp(x, y, centerXX, centerYY, rotation) {
let xFromC = x - centerXX;
let yFromC = y - centerYY;
let d = (xFromC ** 2 + yFromC ** 2) ** 0.5
// let orgAngle = Math.atan2(yFromC/xFromC)
let orgAngle = Math.atan2(xFromC, yFromC)
let tmp = Math.cos(rad(orgAngle - rotation)) * d
// console.log(Math.cos((-90)*(Math.PI/180)))
console.log(orgAngle)
console.log(rad(rotation))
console.log(Math.cos(orgAngle - rad(rotation)) * d)
console.log(d)
// console.log(d)
let newPointX = Math.cos(orgAngle - rad(rotation + 90)) * d + centerXX;
let newPointY = Math.sin(orgAngle - rad(rotation + 90)) * d + centerYY;
return [newPointX, newPointY]
}
function rotatePoint(x, y, rotation) {
let nCos = Math.cos(rad(rotation))
let nSin = Math.sin(rad(rotation))
let newX = x * nCos - y * nSin
let newY = y * nCos + x * nSin
return [newX, newY]
}

View File

@@ -1,128 +1,46 @@
//jshint esversion:8
/**
* Animation Framework - Main Entry Point
*
* This file initializes the animation engine and control system.
* Shapes are registered via shapeRegistry and managed through the Scene.
*/
// ============================================
// Global references (for backward compatibility with shapes)
// ============================================
let c = document.getElementById("myCanvas");
let ctx = c.getContext("2d");
ctx.canvas.width = window.innerWidth;
ctx.canvas.height = window.innerHeight;
let centerX = ctx.canvas.width / 2;
let centerY = ctx.canvas.height / 2;
// ============================================
// Initialize Core Systems
// ============================================
let deg_per_sec = 10;
let targetFps = 60;
let frameDuration = 1000 / targetFps;
// Create animation engine and connect it to the scene
const engine = new AnimationEngine('myCanvas');
engine.setScene(scene);
let rotation = 0; //was = j = angle
let paused = false;
let elapsedTime = 0;
let lastTimestamp = 0;
render_clear();
// Initialize Scene UI
const sceneUI = new SceneUI(scene, 'layers-container');
let drawObj = null;
function createInstance(className, args) {
const classMap = {
NewWave: NewWave,
Countdown: Countdown,
RaysInShape: RaysInShape,
PolyTwistColourWidth: PolyTwistColourWidth,
FloralPhyllo: FloralPhyllo,
Spiral1: Spiral1,
FloralAccident: FloralAccident,
FloralPhyllo_Accident: FloralPhyllo_Accident,
Nodal_expanding: Nodal_expanding,
Phyllotaxis: Phyllotaxis,
SquareTwist_angle: SquareTwist_angle,
EyePrototype: EyePrototype,
CircleExpand: CircleExpand,
MaryFace: MaryFace,
// Add more class constructors here as needed
};
if (classMap.hasOwnProperty(className)) {
return new classMap[className](...args);
} else {
throw new Error(`Unknown class name: ${className}`);
}
}
async function updateDrawObj() {
const shapeSelector = document.getElementById("shape-selector");
const selectedShape = shapeSelector.value;
const config = await fetchConfig(selectedShape);
if (drawObj) {
drawObj.remove(); // Remove the previous instance
}
// Extract default values from the configuration
const defaultValues = config
// .filter((item) => item.type !== "color") // Exclude color inputs
.map((item) => item.defaultValue);
drawObj = createInstance(selectedShape, defaultValues);
// drawObj = await createShapeWithRandomProperties(813311281, config1);
console.log(drawObj)
drawObj.initialise(config);
}
updateDrawObj();
function render(timestamp) {
if (!lastTimestamp) lastTimestamp = timestamp;
const deltaTime = timestamp - lastTimestamp;
const adjustedElapsed = elapsedTime / 100; // Convert to seconds
lastTimestamp = timestamp;
let adjustedDeltaTime;
if (!paused) {
rotation += deg_per_sec / targetFps;
elapsedTime += deltaTime;
adjustedDeltaTime = deltaTime / 100; // Convert to seconds
// console.log(adjustedDeltaTime)
}
// console.log(deltaTime)
// console.log(elapsedTime)
render_clear();
if (drawObj) {
// drawObj.draw(rotation);
drawObj.draw(adjustedElapsed, adjustedDeltaTime);
}
// ctx.font = "48px serif";
// ctx.fillStyle = "white"
// ctx.fillText(Math.floor(elapsedTime) + "ms", centerX - 100, centerY + 400);
// drawCenter(300)
requestAnimationFrame(render);
}
document
.getElementById("shape-selector")
.addEventListener("change", updateDrawObj);
// ============================================
// Event Listeners
// ============================================
let toolbarShowing = true;
document.addEventListener("keydown", toggleSettings);
// Add resize event listener
window.addEventListener('resize', function () {
ctx.canvas.width = window.innerWidth;
ctx.canvas.height = window.innerHeight;
centerX = ctx.canvas.width / 2;
centerY = ctx.canvas.height / 2;
});
// ============================================
// UI Control Functions
// ============================================
function manualToggleSettings() {
console.log("hi")
toolbarShowing = !toolbarShowing;
let tb = document.getElementById("toolbar");
if (toolbarShowing) {
tb.style.display = "flex";
} else {
tb.style.display = "none";
}
tb.style.display = toolbarShowing ? "flex" : "none";
}
function toggleSettings(e) {
@@ -130,43 +48,40 @@ function toggleSettings(e) {
toolbarShowing = !toolbarShowing;
}
if (e.code === "Space") {
paused = !paused;
engine.togglePause();
}
let tb = document.getElementById("toolbar");
if (toolbarShowing) {
tb.style.display = "flex";
} else {
tb.style.display = "none";
}
tb.style.display = toolbarShowing ? "flex" : "none";
}
function TogglePause() {
let pb = document.getElementById("pauseButton");
paused = !paused;
if (paused) {
pb.textContent = "Play";
} else {
pb.textContent = "Pause";
}
const paused = engine.togglePause();
pb.textContent = paused ? "Play" : "Pause";
}
function Reset() {
rotation = 0; //was = j = angle
currentFrame = 0;
engine.reset();
}
function ForwardFrame() {
rotation += deg_per_sec / targetFps; // was = j = innerRotation, now = rotation
currentFrame += 1; // was = i
engine.stepForward();
}
function BackwardFrame() {
rotation -= deg_per_sec / targetFps; // was = j = innerRotation, now = rotation
currentFrame -= 1; // was = i
engine.stepBackward();
}
function ChangeDegPerSec(newValue) {
deg_per_sec = newValue;
engine.setSpeed(newValue);
}
window.onload = requestAnimationFrame(render);
function render_clear() {
engine.clear();
}
// Start animation
window.onload = function() {
engine.start();
};

File diff suppressed because it is too large Load Diff

198
docs/js/presets.js Normal file
View File

@@ -0,0 +1,198 @@
/**
* Built-in Presets
*
* Add your favorite scene configurations here!
* Each preset has: name, description, and data (scene export)
*/
const BUILT_IN_PRESETS = [
{
name: "Hypnotic Spiral",
description: "Classic spiral pattern with smooth colors",
builtIn: true,
data: {
version: 1,
layers: [
{
name: "Spiral1",
visible: true,
paused: false,
collapsed: true,
values: {
width: 400,
n: 6,
piv: 60,
step: 1,
line_width: 2
},
filters: {}
}
]
}
},
{
name: "Floral Bloom",
description: "Organic flower-like pattern",
builtIn: true,
data: {
version: 1,
layers: [
{
name: "FloralPhyllo",
visible: true,
paused: false,
collapsed: true,
values: {
width: 500,
n: 8,
piv: 45,
start: 0,
end: 180,
step: 1,
line_width: 1
},
filters: {}
}
]
}
},
{
name: "Poly Twist Rainbow",
description: "Colorful twisting polygon",
builtIn: true,
data: {
version: 1,
layers: [
{
name: "PolyTwistColourWidth",
visible: true,
paused: false,
collapsed: true,
values: {
sides: 6,
width: 500,
line_width: 2,
depth: 80,
rotation: 0,
colour1: "#ff0066",
colour2: "#00ffff"
},
filters: {}
}
]
}
},
{
name: "Expanding Nodes",
description: "Pulsing nodal pattern",
builtIn: true,
data: {
version: 1,
layers: [
{
name: "Nodal_expanding",
visible: true,
paused: false,
collapsed: true,
values: {
width: 300,
nodeCount: 12,
start: 0,
colour1: "#ffffff",
colour2: "#4a9eff"
},
filters: {}
}
]
}
},
{
name: "Wave Machine",
description: "Mesmerizing sine wave radiation",
builtIn: true,
data: {
version: 1,
layers: [
{
name: "NewWave",
visible: true,
paused: false,
collapsed: false,
values: {
width: 967,
sides: 8,
step: 42,
lineWidth: 3,
limiter: 159,
speedMultiplier: 556
},
filters: {
limiter: [
{
type: "sin",
params: {
min: 42,
max: 240,
rate: 0.5
}
}
]
}
,
controlBounds: {
limiter: {
min: 1,
max: 1000
}
}
}
]
}
},
{
name: "Black Hole Spiral",
description: "Two intertwined spirals",
builtIn: true,
data: {
version: 1,
layers: [
{
name: "Spiral1",
visible: true,
paused: false,
collapsed: true,
values: { width: 350, n: 4, piv: 90, step: 1, line_width: 2,
colour: "#7734D3"
},
filters: {}
},
]
}
},
{
name: "Phyllotaxis Garden",
description: "Nature-inspired spiral arrangement",
builtIn: true,
data: {
version: 1,
layers: [
{
name: "Phyllotaxis",
visible: true,
paused: false,
collapsed: true,
values: {
n: 300,
c: 8,
start: 137.5,
colour1: "#ff6b6b",
colour2: "#4ecdc4",
dotSize: 6,
mode: "spiral"
},
filters: {}
}
]
}
}
];

View File

@@ -0,0 +1,99 @@
/**
* CircleExpand - Expanding concentric circles/hearts animation
*/
class CircleExpand extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 70, defaultValue: 21, property: 'nCircles' },
{ type: 'range', min: 50, max: 150, defaultValue: 150, property: 'gap' },
{ type: 'range', min: 0, max: 1, defaultValue: 1, property: 'linear' },
{ type: 'range', min: 0, max: 1, defaultValue: 1, property: 'heart' },
{ type: 'color', defaultValue: '#fc03cf', property: 'colour1' },
{ type: 'color', defaultValue: '#00fffb', property: 'colour2' },
];
constructor(nCircles, gap, linear, heart, colour1, colour2) {
super();
this.nCircles = nCircles;
this.gap = gap;
this.linear = linear;
this.heart = heart;
this.colour1 = colour1;
this.colour2 = colour2;
}
lerpColor(a, b, amount) {
const ah = +a.replace('#', '0x');
const ar = ah >> 16, ag = ah >> 8 & 0xff, ab = ah & 0xff;
const bh = +b.replace('#', '0x');
const br = bh >> 16, bg = bh >> 8 & 0xff, bb = bh & 0xff;
const rr = ar + amount * (br - ar);
const rg = ag + amount * (bg - ag);
const rb = ab + amount * (bb - ab);
return '#' + ((1 << 24) + (rr << 16) + (rg << 8) + rb | 0).toString(16).slice(1);
}
arraySort(x, y) {
if (x.r > y.r) return 1;
if (x.r < y.r) return -1;
return 0;
}
drawHeart(w, colour) {
ctx.strokeStyle = "black";
ctx.fillStyle = colour;
ctx.lineWidth = 1;
const x = centerX - w / 2;
const y = centerY - w / 2;
ctx.beginPath();
ctx.moveTo(x, y + w / 4);
ctx.quadraticCurveTo(x, y, x + w / 4, y);
ctx.quadraticCurveTo(x + w / 2, y, x + w / 2, y + w / 5);
ctx.quadraticCurveTo(x + w / 2, y, x + w * 3 / 4, y);
ctx.quadraticCurveTo(x + w, y, x + w, y + w / 4);
ctx.quadraticCurveTo(x + w, y + w / 2, x + w * 3 / 4, y + w * 3 / 4);
ctx.lineTo(x + w / 2, y + w);
ctx.lineTo(x + w / 4, y + w * 3 / 4);
ctx.quadraticCurveTo(x, y + w / 2, x, y + w / 4);
ctx.stroke();
ctx.fill();
}
draw(elapsed) {
const rotation = elapsed * 0.9;
ctx.strokeWeight = 1;
ctx.lineWidth = 1;
const arrOfWidths = [];
let intRot;
if (this.linear) {
intRot = Math.floor(rotation * 30) / 100;
} else {
intRot = Math.sin(rad(Math.floor(rotation * 30) / 4)) + rotation / 4;
}
for (let i = 0; i < this.nCircles; i++) {
const width = this.gap * ((intRot + i) % this.nCircles);
const colour = (Math.sin(rad(i * (360 / this.nCircles) - 90)) + 1) / 2;
arrOfWidths.push({ r: width, c: colour });
}
const newArr = arrOfWidths.sort(this.arraySort);
for (let i = this.nCircles - 1; i >= 0; i--) {
const newColour = this.lerpColor(this.colour1, this.colour2, newArr[i].c);
if (this.heart) {
this.drawHeart(newArr[i].r, newColour);
} else {
ctx.beginPath();
ctx.arc(centerX, centerY, newArr[i].r, 0, 2 * Math.PI);
ctx.fillStyle = newColour;
ctx.fill();
ctx.strokeStyle = "black";
ctx.stroke();
}
}
}
}
shapeRegistry.register('CircleExpand', CircleExpand);

View File

@@ -0,0 +1,78 @@
/**
* Countdown - Countdown timer display with progress bar
*/
class Countdown extends BaseShape {
static config = [
{ type: 'range', min: 8000, max: 2000000, defaultValue: 2000000, property: 'milestone' },
];
constructor(milestone) {
super();
this.milestone = milestone;
}
secondsUntilDate(targetDate) {
const now = new Date();
const nzTimeString = targetDate.replace('T', 'T').concat('+12:00');
const target = new Date(nzTimeString);
const difference = target.getTime() - now.getTime();
return Math.round(difference / 1000);
}
drawProgressBar(progress) {
const colourBackground = "#0c2f69";
const colourProgress = "#4287f5";
const barWidth = ctx.canvas.width;
const barHeight = 60;
const barX = 0;
const barY = ctx.canvas.height - barHeight;
ctx.fillStyle = colourBackground;
ctx.beginPath();
ctx.rect(barX, barY, barWidth, barHeight);
ctx.fill();
ctx.fillStyle = colourProgress;
ctx.beginPath();
ctx.rect(barX, barY, (barWidth / 100) * progress, barHeight);
ctx.fill();
}
draw(elapsed) {
let fontSize = 48;
if (ctx.canvas.width < 1000) {
fontSize = 24;
}
ctx.font = fontSize + "px serif";
ctx.fillStyle = "white";
ctx.textAlign = "center";
const futureDate = '2025-06-01T04:30:00';
const seconds = this.secondsUntilDate(futureDate);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(seconds / 3600);
const percentRounded = (((elapsed / 1000) / seconds) * 100).toFixed(8);
ctx.fillText(seconds + " Seconds", centerX, centerY - 200);
ctx.fillText(minutes + " Minutes", centerX, centerY - 100);
ctx.fillText(hours + " Hours", centerX, centerY);
ctx.fillText(percentRounded + "% Closer", centerX, centerY + 300);
const milestoneSeconds = this.milestone;
const target = new Date(futureDate + '+12:00');
const milestoneDate = new Date(target.getTime() - milestoneSeconds * 1000).toLocaleString();
ctx.fillText(milestoneDate, centerX, centerY + 100);
ctx.fillText("^-- " + milestoneSeconds + " milestone", centerX, centerY + 200);
const canvasWidth = ctx.canvas.width;
const secondsPerPixel = (seconds / canvasWidth);
const secondsUntilFirstPixel = secondsPerPixel - (elapsed / 10);
ctx.fillText("Time until first pixel: " + Math.round(secondsUntilFirstPixel) + " seconds", centerX, centerY + 350);
this.drawProgressBar(percentRounded);
}
}
shapeRegistry.register('Countdown', Countdown);

View File

@@ -0,0 +1,196 @@
/**
* EyePrototype - Animated eye with blinking, spiral, and hypnotic effects
*/
class EyePrototype extends BaseShape {
static config = [
{ type: 'range', min: -400, max: 400, defaultValue: 0, property: 'x' },
{ type: 'range', min: -400, max: 400, defaultValue: 0, property: 'y' },
{ type: 'range', min: -180, max: 180, defaultValue: 0, property: 'rotate' },
{ type: 'range', min: 0, max: 1, defaultValue: 1, property: 'flip' },
{ type: 'range', min: 1, max: 800, defaultValue: 400, property: 'width' },
{ type: 'range', min: 1, max: 100, defaultValue: 5, property: 'blink_speed' },
{ type: 'range', min: 0, max: 1, defaultValue: 0, property: 'draw_spiral' },
{ type: 'range', min: 0, max: 1, defaultValue: 1, property: 'spiral_full' },
{ type: 'range', min: 0, max: 1, defaultValue: 0, property: 'draw_pupil' },
{ type: 'range', min: 0, max: 1, defaultValue: 0, property: 'draw_expand' },
{ type: 'range', min: 0, max: 1, defaultValue: 1, property: 'draw_hypno' },
{ type: 'range', min: 1, max: 10, defaultValue: 1, property: 'line_width' },
{ type: 'color', defaultValue: '#00fffb', property: 'colourPupil' },
{ type: 'color', defaultValue: '#ff0000', property: 'colourSpiral' },
{ type: 'color', defaultValue: '#00fffb', property: 'colourExpand' },
{ type: 'range', min: 0, max: 1, defaultValue: 1, property: 'draw_eyelid' },
];
constructor(x, y, rotate, flip, width, blink_speed, draw_spiral, spiral_full, draw_pupil, draw_expand, draw_hypno, line_width, colourPupil, colourSpiral, colourExpand) {
super();
this.x = x;
this.y = y;
this.rotate = rotate;
this.flip = flip;
this.width = width;
this.blink_speed = blink_speed;
this.line_width = line_width;
this.step = 0;
this.opening = true;
this.counter = 0;
this.cooldown = 0;
this.draw_spiral = draw_spiral;
this.spiral_full = spiral_full;
this.draw_pupil = draw_pupil;
this.draw_expand = draw_expand;
this.draw_hypno = draw_hypno;
this.colourPupil = colourPupil;
this.colourSpiral = colourSpiral;
this.colourExpand = colourExpand;
this.centerPulse = new CircleExpand(10, 30, 1, 0, "#2D81FC", "#FC0362");
}
drawEyelid(rotation) {
ctx.strokeStyle = "orange";
const relCenterX = centerX + this.x;
const relCenterY = centerY + this.y;
rotation *= (this.speedMultiplier / 100);
ctx.lineWidth = 1;
ctx.beginPath();
let newPoint = 0;
let newPoint1 = 0;
const addedRotate = this.flip ? 90 : 0;
newPoint = rotatePoint(-this.width / 2, 0, this.rotate + addedRotate);
ctx.moveTo(relCenterX + newPoint[0], relCenterY + newPoint[1]);
newPoint = rotatePoint(0, -rotation / 400 * this.width, this.rotate + addedRotate);
newPoint1 = rotatePoint(this.width / 2, 0, this.rotate + addedRotate);
ctx.quadraticCurveTo(relCenterX + newPoint[0], relCenterY + newPoint[1], relCenterX + newPoint1[0], relCenterY + newPoint1[1]);
newPoint = rotatePoint(-this.width / 2, 0, this.rotate + addedRotate);
ctx.moveTo(relCenterX + newPoint[0], relCenterY + newPoint[1]);
newPoint = rotatePoint(0, +rotation / 400 * this.width, this.rotate + addedRotate);
newPoint1 = rotatePoint(this.width / 2, 0, this.rotate + addedRotate);
ctx.quadraticCurveTo(relCenterX + newPoint[0], relCenterY + newPoint[1], relCenterX + newPoint1[0], relCenterY + newPoint1[1]);
ctx.stroke();
}
eyelidCut(rotation) {
const relCenterX = centerX + this.x;
const relCenterY = centerY + this.y;
let newPoint = 0;
let newPoint1 = 0;
const addedRotate = this.flip ? 90 : 0;
const squarePath = new Path2D();
newPoint = rotatePoint(-this.width / 2, 0, this.rotate + addedRotate);
squarePath.moveTo(relCenterX + newPoint[0], relCenterY + newPoint[1]);
newPoint = rotatePoint(0, -rotation / 400 * this.width, this.rotate + addedRotate);
newPoint1 = rotatePoint(this.width / 2, 0, this.rotate + addedRotate);
squarePath.quadraticCurveTo(relCenterX + newPoint[0], relCenterY + newPoint[1], relCenterX + newPoint1[0], relCenterY + newPoint1[1]);
newPoint = rotatePoint(-this.width / 2, 0, this.rotate + addedRotate);
squarePath.moveTo(relCenterX + newPoint[0], relCenterY + newPoint[1]);
newPoint = rotatePoint(0, +rotation / 400 * this.width, this.rotate + addedRotate);
newPoint1 = rotatePoint(this.width / 2, 0, this.rotate + addedRotate);
squarePath.quadraticCurveTo(relCenterX + newPoint[0], relCenterY + newPoint[1], relCenterX + newPoint1[0], relCenterY + newPoint1[1]);
ctx.clip(squarePath);
}
drawGrowEye(step) {
ctx.strokeStyle = this.colourExpand;
ctx.beginPath();
ctx.lineWidth = 5;
ctx.arc(centerX + this.x, centerY + this.y, step, 0, 2 * Math.PI);
ctx.stroke();
}
drawCircle(step) {
ctx.strokeStyle = this.colourPupil;
ctx.beginPath();
ctx.lineWidth = 5;
ctx.arc(centerX + this.x, centerY + this.y, step, 0, 2 * Math.PI);
ctx.stroke();
}
drawSpiral(step) {
ctx.strokeStyle = this.colourSpiral;
const a = 1;
const b = 5;
ctx.moveTo(centerX, centerY);
ctx.beginPath();
const max = this.spiral_full ? this.width : this.width / 2;
for (let i = 0; i < max; i++) {
const angle = 0.1 * i;
const x = centerX + (a + b * angle) * Math.cos(angle + step / 2);
const y = centerY + (a + b * angle) * Math.sin(angle + step / 2);
ctx.lineTo(x + this.x, y + this.y);
}
ctx.lineWidth = 3;
ctx.stroke();
}
stepFunc() {
if (this.cooldown !== 0) {
this.cooldown--;
} else {
if (this.opening === true) {
if (this.step >= 200) {
this.cooldown = 200;
this.opening = false;
this.step -= this.blink_speed;
} else {
this.step += this.blink_speed;
}
} else {
if (this.step <= 0) {
this.opening = true;
this.step += this.blink_speed;
} else {
this.step -= this.blink_speed;
}
}
}
}
draw(elapsed) {
const speedMult = 50;
const waitTime = this.blink_speed;
const cap = 200;
const d = waitTime * speedMult * 10;
const a = cap * 2 + d;
const outputRotation = Math.min(Math.abs((Math.floor(elapsed * speedMult) % a) - a / 2 - d / 2), cap);
ctx.fillStyle = "black";
ctx.save();
this.drawEyelid(outputRotation);
this.eyelidCut(outputRotation);
if (Math.floor(this.counter % (this.width / 4)) === 0) {
this.counter = 0;
}
ctx.fillStyle = "black";
ctx.fillRect(this.x - this.width / 2 + centerX, 0, this.width, ctx.canvas.height);
if (this.draw_expand) {
this.drawGrowEye(this.width / 4 + this.counter);
}
if (this.draw_hypno) {
this.centerPulse.draw(elapsed);
}
if (this.draw_spiral) {
this.drawSpiral(elapsed);
}
if (this.draw_pupil) {
this.drawCircle(this.width / 4);
}
ctx.restore();
this.stepFunc();
this.counter++;
}
}
shapeRegistry.register('EyePrototype', EyePrototype);

View File

@@ -0,0 +1,59 @@
/**
* FloralAccident - Accidental floral spiral pattern variant
*/
class FloralAccident extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 50, defaultValue: 20, property: 'sides' },
{ type: 'range', min: 1, max: 600, defaultValue: 240, property: 'width' },
{ type: 'color', defaultValue: '#4287f5', property: 'colour' },
];
constructor(sides, width, colour) {
super();
this.sides = sides;
this.width = width;
this.colour = colour;
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 100);
const rot = Math.round((this.sides - 2) * 180 / this.sides * 2);
const piv = 360 / this.sides;
let stt = 0.5 * Math.PI - rad(rot);
let end = 0;
const n = this.width / ((this.width / 10) * (this.width / 10));
for (let i = 1; i < this.sides + 1; i++) {
end = stt + rad(rot);
ctx.beginPath();
ctx.arc(
centerX + Math.cos(rad(90 + piv * i + rotation)) * this.width,
centerY + Math.sin(rad(90 + piv * i + rotation)) * this.width,
this.width,
stt - (stt - end + rad(rotation)) / 2,
end + rad(n),
0
);
ctx.strokeStyle = this.colour;
ctx.stroke();
ctx.beginPath();
ctx.arc(
centerX + Math.cos(rad(90 + piv * i - rotation)) * this.width,
centerY + Math.sin(rad(90 + piv * i - rotation)) * this.width,
this.width,
stt,
end - (end - stt - rad(rotation)) / 2 + rad(n),
0
);
ctx.strokeStyle = this.colour;
ctx.stroke();
stt = end + -(rad(rot - piv));
}
}
}
shapeRegistry.register('FloralAccident', FloralAccident);

View File

@@ -0,0 +1,40 @@
/**
* FloralPhyllo - Phyllotaxis-based floral pattern with eyelid shapes
*/
class FloralPhyllo extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 600, defaultValue: 300, property: 'width' },
{ type: 'range', min: 1, max: 300, defaultValue: 150, property: 'depth' },
{ type: 'range', min: 0, max: 3141, defaultValue: 0, property: 'start' },
{ type: 'color', defaultValue: '#4287f5', property: 'colour1' },
{ type: 'color', defaultValue: '#FC0362', property: 'colour2' },
];
constructor(width, depth, start, colour1, colour2) {
super();
this.width = width;
this.depth = depth;
this.start = start;
this.colour1 = colour1;
this.colour2 = colour2;
this.speedMultiplier = 500;
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 500) + this.start;
const c = 1;
for (let n = this.depth; n > 0; n -= 1) {
const ncolour = LerpHex(this.colour1, this.colour2, n / this.depth);
const a = n * rotation / 1000;
const r = c * Math.sqrt(n);
const x = r * Math.cos(a) + centerX;
const y = r * Math.sin(a) + centerY;
drawEyelid(n * 2.4 + 40, x, y, ncolour);
}
}
}
shapeRegistry.register('FloralPhyllo', FloralPhyllo);

View File

@@ -0,0 +1,37 @@
/**
* FloralPhyllo_Accident - Phyllotaxis pattern with accidental eyelid variation
*/
class FloralPhyllo_Accident extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 50, defaultValue: 20, property: 'sides' },
{ type: 'range', min: 1, max: 600, defaultValue: 240, property: 'width' },
{ type: 'color', defaultValue: '#2D81FC', property: 'colour1' },
{ type: 'color', defaultValue: '#FC0362', property: 'colour2' },
];
constructor(sides, width, colour1, colour2) {
super();
this.sides = sides;
this.width = width;
this.colour1 = colour1;
this.colour2 = colour2;
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 100);
const c = 24;
for (let n = 0; n < 300; n += 1) {
const ncolour = LerpHex(this.colour1, this.colour2, Math.cos(rad(n / 2)));
const a = n * (rotation / 1000 + 100);
const r = c * Math.sqrt(n);
const x = r * Math.cos(a) + centerX;
const y = r * Math.sin(a) + centerY;
drawEyelidAccident(x, y);
}
}
}
shapeRegistry.register('FloralPhyllo_Accident', FloralPhyllo_Accident);

View File

@@ -0,0 +1,42 @@
/**
* MaryFace - Face overlay with animated eyes
*/
class MaryFace extends BaseShape {
static config = [
{ type: 'range', min: -400, max: 400, defaultValue: -110, property: 'x1' },
{ type: 'range', min: -400, max: 400, defaultValue: -140, property: 'y1' },
{ type: 'range', min: -180, max: 180, defaultValue: 18, property: 'rotate1' },
{ type: 'range', min: 0, max: 400, defaultValue: 160, property: 'width1' },
{ type: 'range', min: -400, max: 400, defaultValue: 195, property: 'x2' },
{ type: 'range', min: -400, max: 400, defaultValue: -30, property: 'y2' },
{ type: 'range', min: -180, max: 180, defaultValue: 18, property: 'rotate2' },
{ type: 'range', min: 0, max: 400, defaultValue: 160, property: 'width2' },
];
constructor(x1, y1, rotate1, width1, x2, y2, rotate2, width2) {
super();
this.x1 = x1;
this.y1 = y1;
this.rotate1 = rotate1;
this.width1 = width1;
this.x2 = x2;
this.y2 = y2;
this.rotate2 = rotate2;
this.width2 = width2;
this.eye1 = new EyePrototype(x1, y1, rotate1, 0, width1, 10, 1, 1, 0, 0, 0, 1, "#00fffb", "#00fffb", "#00fffb");
this.eye2 = new EyePrototype(x2, y2, rotate2, 0, width2, 10, 1, 1, 0, 0, 0, 1, "#00fffb", "#00fffb", "#00fffb");
this.eye3 = new EyePrototype(110, -280, rotate2 + 2, 1, width2, 10, 1, 1, 0, 0, 0, 1, "#00fffb", "#00fffb", "#00fffb");
}
draw(elapsed) {
const img = new Image();
img.src = "maryFace.png";
ctx.drawImage(img, centerX - img.width / 2, centerY - img.height / 2);
this.eye1.draw(elapsed);
this.eye2.draw(elapsed);
this.eye3.draw(elapsed);
}
}
shapeRegistry.register('MaryFace', MaryFace);

52
docs/js/shapes/NewWave.js Normal file
View File

@@ -0,0 +1,52 @@
/**
* NewWave - Sine wave pattern radiating from center
*/
class NewWave extends BaseShape {
static config = [
{ type: 'range', min: 300, max: 1600, defaultValue: 342, property: 'width' },
{ type: 'range', min: 2, max: 40, defaultValue: 4, property: 'sides' },
{ type: 'range', min: 1, max: 100, defaultValue: 1, property: 'step' },
{ type: 'range', min: 1, max: 10, defaultValue: 4, property: 'lineWidth' },
{ type: 'range', min: 100, max: 1000, defaultValue: 100, property: 'limiter' },
];
constructor(width, sides, step, lineWidth, limiter) {
super();
this.width = width;
this.sides = sides;
this.step = step;
this.lineWidth = lineWidth;
this.limiter = limiter;
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * this.speedMultiplier / 400;
ctx.lineWidth = this.lineWidth;
for (let j = 0; j < this.sides; j++) {
const radRotation = rad(360 / this.sides * j);
const inverter = 1 - (j % 2) * 2;
let lastX = centerX;
let lastY = centerY;
for (let i = 0; i < this.width; i += this.step) {
ctx.beginPath();
ctx.moveTo(lastX, lastY);
ctx.strokeStyle = colourToText(lerpRGB([255, 51, 170], [51, 170, 255], i / this.width));
const x = i;
const y = (Math.sin(-i * inverter / 30 + rotation * inverter) * i / (this.limiter / 100));
const xRotated = x * Math.cos(radRotation) - y * Math.sin(radRotation);
const yRotated = x * Math.sin(radRotation) + y * Math.cos(radRotation);
lastX = centerX + xRotated;
lastY = centerY + yRotated;
ctx.lineTo(centerX + xRotated, centerY + yRotated);
ctx.stroke();
}
}
}
}
shapeRegistry.register('NewWave', NewWave);

View File

@@ -0,0 +1,53 @@
/**
* Nodal_expanding - Expanding nodal pattern with color gradient
*/
class Nodal_expanding extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 100, defaultValue: 5, property: 'expand' },
{ type: 'range', min: 1, max: 1000, defaultValue: 150, property: 'points' },
{ type: 'range', min: 1, max: 360, defaultValue: 0, property: 'start' },
{ type: 'range', min: 1, max: 10, defaultValue: 6, property: 'line_width' },
{ type: 'color', defaultValue: '#2D81FC', property: 'colour1' },
{ type: 'color', defaultValue: '#FC0362', property: 'colour2' },
{ type: 'range', min: 0, max: 10, defaultValue: 5, property: 'colour_change' },
];
constructor(expand, points, start, line_width, colour1, colour2, colour_change) {
super();
this.expand = expand;
this.points = points;
this.start = start;
this.line_width = line_width;
this.colour1 = colour1;
this.colour2 = colour2;
this.colour_change = colour_change;
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 1000);
const angle = (360 / 3000 * rotation) + this.start;
let length = this.expand;
for (let z = 1; z <= this.points; z++) {
ctx.beginPath();
const ncolour = LerpHex(this.colour1, this.colour2, z / this.points);
ctx.moveTo(
centerX + (Math.cos(rad(angle * (z - 1) + 0)) * (length - this.expand)),
centerY + (Math.sin(rad(angle * (z - 1) + 0)) * (length - this.expand))
);
ctx.lineTo(
centerX + (Math.cos(rad(angle * z + 0)) * length),
centerY + (Math.sin(rad(angle * z + 0)) * length)
);
length += this.expand;
ctx.lineWidth = this.line_width;
ctx.strokeStyle = ncolour;
ctx.lineCap = "round";
ctx.stroke();
}
}
}
shapeRegistry.register('Nodal_expanding', Nodal_expanding);

View File

@@ -0,0 +1,95 @@
/**
* Phyllotaxis - Classic phyllotaxis pattern with multiple wave modes
*/
class Phyllotaxis extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 40, defaultValue: 24, property: 'width' },
{ type: 'range', min: 1, max: 40, defaultValue: 10, property: 'size' },
{ type: 'range', min: 1, max: 40, defaultValue: 4, property: 'sizeMin' },
{ type: 'range', min: 0, max: 3141, defaultValue: 0, property: 'start' },
{ type: 'range', min: 1, max: 10000, defaultValue: 300, property: 'nMax' },
{ type: 'range', min: 0, max: 2, defaultValue: 0, property: 'wave' },
{ type: 'range', min: 1, max: 12, defaultValue: 2, property: 'spiralProngs' },
{ type: 'color', defaultValue: '#2D81FC', property: 'colour1' },
{ type: 'color', defaultValue: '#FC0362', property: 'colour2' },
];
constructor(width, size, sizeMin, start, nMax, wave, spiralProngs, colour1, colour2) {
super();
this.width = width;
this.size = size;
this.sizeMin = sizeMin;
this.start = start;
this.nMax = nMax;
this.wave = wave;
this.spiralProngs = spiralProngs;
this.colour1 = colour1;
this.colour2 = colour2;
}
drawWave(angle) {
angle /= 1000;
const startColor = [45, 129, 252];
const endColor = [252, 3, 98];
const distanceMultiplier = 3;
const maxIterations = this.nMax;
for (let n = 0; n < maxIterations; n++) {
ctx.beginPath();
const nColor = lerpRGB(startColor, endColor, Math.cos(rad(n / 2)));
const nAngle = n * angle + Math.sin(rad(n * 1 + angle * 40000)) / 2;
const radius = distanceMultiplier * n;
const xCoord = radius * Math.cos(nAngle) + centerX;
const yCoord = radius * Math.sin(nAngle) + centerY;
ctx.arc(xCoord, yCoord, this.size, 0, 2 * Math.PI);
ctx.fillStyle = colourToText(nColor);
ctx.fill();
}
}
drawSpiral(angle) {
angle /= 5000;
const startColor = [45, 129, 252];
const endColor = [252, 3, 98];
const distanceMultiplier = 2;
const maxIterations = 1000;
ctx.beginPath();
ctx.moveTo(centerX, centerY);
for (let n = 0; n < maxIterations; n++) {
const nAngle = n * angle + Math.sin(angle * n * this.spiralProngs);
const radius = distanceMultiplier * n;
const xCoord = radius * Math.cos(nAngle) + centerX;
const yCoord = radius * Math.sin(nAngle) + centerY;
ctx.lineTo(xCoord, yCoord);
}
ctx.stroke();
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 300) + this.start;
const sizeMultiplier = this.nMax / this.size + (5 - 3);
if (this.wave === 1) {
this.drawWave(rotation);
} else if (this.wave === 2) {
this.drawSpiral(rotation);
} else {
for (let n = 0; n < this.nMax; n += 1) {
const ncolour = LerpHex(this.colour1, this.colour2, n / this.nMax);
const a = n * (rotation / 1000);
const r = this.width * Math.sqrt(n);
const x = r * Math.cos(a) + centerX;
const y = r * Math.sin(a) + centerY;
ctx.beginPath();
ctx.arc(x, y, (n / sizeMultiplier) + this.sizeMin, 0, 2 * Math.PI);
ctx.fillStyle = ncolour;
ctx.fill();
}
}
}
}
shapeRegistry.register('Phyllotaxis', Phyllotaxis);

View File

@@ -0,0 +1,50 @@
/**
* PolyTwistColourWidth - Twisted polygon with color gradient
*/
class PolyTwistColourWidth extends BaseShape {
static config = [
{ type: 'range', min: 3, max: 10, defaultValue: 5, property: 'sides' },
{ type: 'range', min: 400, max: 2000, defaultValue: 400, property: 'width' },
{ type: 'range', min: 2, max: 5, defaultValue: 5, property: 'line_width' },
{ type: 'range', min: 1, max: 100, defaultValue: 50, property: 'depth' },
{ type: 'range', min: -180, max: 180, defaultValue: -90, property: 'rotation' },
{ type: 'color', defaultValue: '#4287f5', property: 'colour1' },
{ type: 'color', defaultValue: '#42f57b', property: 'colour2' },
];
constructor(sides, width, line_width, depth, rotation, colour1, colour2) {
super();
this.sides = sides;
this.width = width;
this.line_width = line_width;
this.depth = depth;
this.rotation = rotation;
this.colour1 = colour1;
this.colour2 = colour2;
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 100);
let out_angle = 0;
const innerAngle = 180 - ((this.sides - 2) * 180) / this.sides;
const scopeAngle = rotation - (innerAngle * Math.floor(rotation / innerAngle));
if (scopeAngle < innerAngle / 2) {
out_angle = innerAngle / (2 * Math.cos((2 * Math.PI * scopeAngle) / (3 * innerAngle))) - innerAngle / 2;
} else {
out_angle = -innerAngle / (2 * Math.cos(((2 * Math.PI) / 3) - ((2 * Math.PI * scopeAngle) / (3 * innerAngle)))) + (innerAngle * 3) / 2;
}
const minWidth = Math.sin(rad(innerAngle / 2)) * (0.5 / Math.tan(rad(innerAngle / 2))) * 2;
const widthMultiplier = minWidth / Math.sin(Math.PI / 180 * (90 + innerAngle / 2 - out_angle + innerAngle * Math.floor(out_angle / innerAngle)));
for (let i = 0; i < this.depth; i++) {
const fraction = i / this.depth;
const ncolour = LerpHex(this.colour1, this.colour2, fraction);
DrawPolygon(this.sides, this.width * widthMultiplier ** i, out_angle * i + this.rotation, ncolour, this.line_width);
}
}
}
shapeRegistry.register('PolyTwistColourWidth', PolyTwistColourWidth);

View File

@@ -0,0 +1,289 @@
/**
* RaysInShape - Rays bouncing within a box with center-returning trails
*/
class RaysInShape extends BaseShape {
static config = [
{ type: 'range', min: 50, max: 1000, defaultValue: 500, property: 'rays', callback: (instance, newValue) => instance.setRays(newValue) },
{ type: 'range', min: 1, max: 30, defaultValue: 2, property: 'speed' },
{ type: 'checkbox', defaultValue: true, property: 'doesWave' },
{ type: 'range', min: 1, max: 200, defaultValue: 100, property: 'speedVertRate' },
{ type: 'range', min: 1, max: 200, defaultValue: 100, property: 'speedHorrRate' },
{ type: 'range', min: 1, max: 200, defaultValue: 100, property: 'speedVert' },
{ type: 'range', min: 1, max: 200, defaultValue: 100, property: 'speedHorr' },
{ type: 'range', min: 10, max: 2000, defaultValue: 800, property: 'boxSize' },
{ type: 'range', min: 1, max: 80, defaultValue: 5, property: 'trailLength' },
{ type: 'range', min: 1, max: 500, defaultValue: 5, property: 'lineWidth' },
{ type: 'checkbox', defaultValue: false, property: 'fade' },
{ type: 'color', defaultValue: '#43dbad', property: 'colourFree' },
{ type: 'color', defaultValue: '#f05c79', property: 'colourContained' },
{ type: 'header', text: '--CollisionBox---' },
{ type: 'checkbox', defaultValue: false, property: 'boxVisible' },
];
constructor(rays, speed, doesWave, speedVertRate, speedHorrRate, speedVert, speedHorr, boxSize, trailLength = 50, lineWidth, fade, colourFree, colourContained, boxVisible) {
super();
this.rays = rays;
this.speed = speed;
this.speedVert = speedVert;
this.speedHorr = speedHorr;
this.boxSize = boxSize;
this.trailLength = trailLength;
this.rayObjects = [];
this.centerRays = [];
this.lineWidth = lineWidth;
this.boxVisible = boxVisible;
this.doesWave = doesWave;
this.colourFree = colourFree;
this.colourContained = colourContained;
this.speedHorrRate = speedHorrRate;
this.speedVertRate = speedVertRate;
this.fade = fade;
}
initializeControls(controlManager) {
super.initializeControls(controlManager);
this.prepareRayObjects();
}
prepareRayObjects() {
this.rayObjects = [];
for (let i = 0; i < this.rays; i++) {
const angle = (360 / this.rays) * i;
this.rayObjects.push({
angle: angle,
lastX: centerX,
lastY: centerY,
positions: [{ x: centerX, y: centerY, angle: angle }]
});
}
this.centerRays = [];
}
createCenterRay(x, y) {
const dx = centerX - x;
const dy = centerY - y;
const angleToCenter = Math.atan2(dy, dx) * 180 / Math.PI;
this.centerRays.push({
positions: [{ x: x, y: y }],
angle: angleToCenter,
reachedCenter: false
});
}
updateCenterRays(deltaTime) {
const centerThreshold = 5;
const maxDistance = 2000;
for (let i = 0; i < this.centerRays.length; i++) {
const ray = this.centerRays[i];
if (ray.reachedCenter) {
if (ray.positions.length > 0) {
ray.positions.shift();
}
if (ray.positions.length <= 1) {
this.centerRays.splice(i, 1);
i--;
continue;
}
} else {
const currentPos = ray.positions[ray.positions.length - 1];
const dx = (this.speedHorr / 100) * this.speed * Math.cos(rad(ray.angle));
const dy = (this.speedVert / 100) * this.speed * Math.sin(rad(ray.angle));
const newX = currentPos.x + dx;
const newY = currentPos.y + dy;
const distFromOrigin = Math.sqrt(
Math.pow(newX - centerX, 2) + Math.pow(newY - centerY, 2)
);
if (distFromOrigin > maxDistance) {
this.centerRays.splice(i, 1);
i--;
continue;
}
ray.positions.push({ x: newX, y: newY });
const distToCenter = Math.sqrt(
Math.pow(newX - centerX, 2) + Math.pow(newY - centerY, 2)
);
if (distToCenter <= centerThreshold) {
ray.reachedCenter = true;
}
while (ray.positions.length > this.trailLength) {
ray.positions.shift();
}
}
for (let j = 1; j < ray.positions.length; j++) {
const prev = ray.positions[j - 1];
const curr = ray.positions[j];
let alpha = 1;
if (this.fade) {
alpha = (j / ray.positions.length) * 0.8 + 0.2;
}
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(curr.x, curr.y);
const col = hexToRgb(this.colourFree);
ctx.strokeStyle = `rgba(${col.r}, ${col.g}, ${col.b}, ${alpha})`;
ctx.stroke();
}
}
}
setRays(newValue) {
this.rays = newValue;
this.prepareRayObjects();
}
draw(elapsed, deltaTime) {
deltaTime *= this.speedMultiplier / 100;
this.updateFilters(elapsed);
if (this.doesWave) {
const vertRate = this.speedVertRate / 100;
const horrRate = this.speedHorrRate / 100;
this.speedVert = Math.sin(elapsed / 10 * vertRate) * 85 + 100;
this.speedHorr = Math.sin(elapsed / 10 * horrRate) * 85 + 100;
updateControlInput(this.speedVert, "speedVert");
updateControlInput(this.speedHorr, "speedHorr");
}
const boxLeft = centerX - this.boxSize / 2;
const boxRight = centerX + this.boxSize / 2;
const boxTop = centerY - this.boxSize / 2;
const boxBottom = centerY + this.boxSize / 2;
if (this.boxVisible) {
ctx.strokeStyle = "white";
ctx.lineWidth = 1;
ctx.strokeRect(boxLeft, boxTop, this.boxSize, this.boxSize);
}
ctx.lineWidth = this.lineWidth;
for (let j = 0; j < this.rayObjects.length; j++) {
const ray = this.rayObjects[j];
const currentPos = ray.positions[ray.positions.length - 1];
let dx = (this.speedHorr / 100) * this.speed * Math.cos(rad(ray.angle));
let dy = (this.speedVert / 100) * this.speed * Math.sin(rad(ray.angle));
let newX = currentPos.x + dx;
let newY = currentPos.y + dy;
let collisionType = null;
const oldAngle = ray.angle;
if (newX < boxLeft || newX > boxRight) {
const collisionX = newX < boxLeft ? boxLeft : boxRight;
const collisionRatio = (collisionX - currentPos.x) / dx;
const collisionY = currentPos.y + dy * collisionRatio;
ray.positions.push({
x: collisionX,
y: collisionY,
angle: oldAngle,
collision: 'horizontal'
});
this.createCenterRay(collisionX, collisionY);
ray.angle = 180 - ray.angle;
ray.angle = ((ray.angle % 360) + 360) % 360;
const remainingRatio = 1 - collisionRatio;
dx = remainingRatio * (this.speedHorr / 100) * this.speed * Math.cos(rad(ray.angle));
dy = remainingRatio * (this.speedVert / 100) * this.speed * Math.sin(rad(ray.angle));
newX = collisionX + dx;
newY = collisionY + dy;
collisionType = 'horizontal';
}
if (newY < boxTop || newY > boxBottom) {
if (collisionType === null) {
const collisionY = newY < boxTop ? boxTop : boxBottom;
const collisionRatio = (collisionY - currentPos.y) / dy;
const collisionX = currentPos.x + dx * collisionRatio;
ray.positions.push({
x: collisionX,
y: collisionY,
angle: oldAngle,
collision: 'vertical'
});
this.createCenterRay(collisionX, collisionY);
ray.angle = 360 - ray.angle;
ray.angle = ((ray.angle % 360) + 360) % 360;
const remainingRatio = 1 - collisionRatio;
dx = remainingRatio * (this.speedHorr / 100) * this.speed * Math.cos(rad(ray.angle));
dy = remainingRatio * (this.speedVert / 100) * this.speed * Math.sin(rad(ray.angle));
newX = collisionX + dx;
newY = collisionY + dy;
} else {
newX = Math.max(boxLeft, Math.min(newX, boxRight));
newY = Math.max(boxTop, Math.min(newY, boxBottom));
ray.positions.push({
x: newX,
y: newY,
angle: ray.angle,
collision: 'corner'
});
this.createCenterRay(newX, newY);
}
}
newX = Math.max(boxLeft, Math.min(newX, boxRight));
newY = Math.max(boxTop, Math.min(newY, boxBottom));
if (collisionType === null) {
ray.positions.push({
x: newX,
y: newY,
angle: ray.angle
});
}
while (ray.positions.length > this.trailLength) {
ray.positions.shift();
}
for (let i = 1; i < ray.positions.length; i++) {
const prev = ray.positions[i - 1];
const curr = ray.positions[i];
let alpha = 1;
if (this.fade) {
alpha = (i / ray.positions.length) * 0.8 + 0.2;
}
ctx.beginPath();
ctx.moveTo(prev.x, prev.y);
ctx.lineTo(curr.x, curr.y);
if (curr.collision) {
ctx.strokeStyle = `rgba(255, 255, 0, ${alpha})`;
} else {
const col = hexToRgb(this.colourContained);
ctx.strokeStyle = `rgba(${col.r}, ${col.g}, ${col.b}, ${alpha})`;
}
ctx.stroke();
}
}
this.updateCenterRays(deltaTime);
}
}
shapeRegistry.register('RaysInShape', RaysInShape);

60
docs/js/shapes/Spiral1.js Normal file
View File

@@ -0,0 +1,60 @@
/**
* Spiral1 - Dual-direction spiral pattern
*/
class Spiral1 extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 50, defaultValue: 20, property: 'sides' },
{ type: 'range', min: 1, max: 600, defaultValue: 240, property: 'width' },
{ type: 'color', defaultValue: '#4287f5', property: 'colour' },
];
constructor(sides, width, colour) {
super();
this.sides = sides;
this.width = width;
this.colour = colour;
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 100);
const rot = Math.round((this.sides - 2) * 180 / this.sides * 2);
const piv = 360 / this.sides;
let stt = 0.5 * Math.PI - rad(rot);
let end = 0;
const n = this.width / ((this.width / 10) * (this.width / 10));
for (let i = 1; i < this.sides + 1; i++) {
end = stt + rad(rot);
ctx.lineWidth = 5;
ctx.beginPath();
ctx.arc(
centerX + Math.cos(rad(90 + piv * i + rotation)) * this.width,
centerY + Math.sin(rad(90 + piv * i + rotation)) * this.width,
this.width,
stt + rad(rotation) - (stt - end) / 2,
end + rad(rotation) + rad(n),
0
);
ctx.strokeStyle = this.colour;
ctx.stroke();
ctx.beginPath();
ctx.arc(
centerX + Math.cos(rad(90 + piv * i - rotation)) * this.width,
centerY + Math.sin(rad(90 + piv * i - rotation)) * this.width,
this.width,
stt - rad(rotation),
end - (end - stt) / 2 + rad(n) - rad(rotation),
0
);
ctx.strokeStyle = this.colour;
ctx.stroke();
stt = end + -(rad(rot - piv));
}
}
}
shapeRegistry.register('Spiral1', Spiral1);

View File

@@ -0,0 +1,42 @@
/**
* SquareTwist_angle - Twisted square pattern with angle-based scaling
*/
class SquareTwist_angle extends BaseShape {
static config = [
{ type: 'range', min: 1, max: 800, defaultValue: 400, property: 'width' },
{ type: 'range', min: 1, max: 10, defaultValue: 1, property: 'line_width' },
{ type: 'color', defaultValue: '#2D81FC', property: 'colour1' },
];
constructor(width, line_width, colour1) {
super();
this.width = width;
this.line_width = line_width;
this.colour1 = colour1;
}
drawSquare(angle, size, colour) {
ctx.save();
ctx.translate(centerX, centerY);
ctx.rotate(rad(angle + 180));
ctx.beginPath();
ctx.strokeStyle = colour;
ctx.lineWidth = this.line_width;
ctx.rect(-size / 2, -size / 2, size, size);
ctx.stroke();
ctx.restore();
}
draw(elapsed) {
this.updateFilters(elapsed);
const rotation = elapsed * (this.speedMultiplier / 100);
const out_angle = rotation;
const widthMultiplier = 1 / (2 * Math.sin(Math.PI / 180 * (130 - out_angle + 90 * Math.floor(out_angle / 90)))) + 0.5;
for (let i = 0; i < 25; i++) {
this.drawSquare(rotation * i, this.width * widthMultiplier ** i, this.colour1);
}
}
}
shapeRegistry.register('SquareTwist_angle', SquareTwist_angle);

290
docs/js/utils/helpers.js Normal file
View File

@@ -0,0 +1,290 @@
/**
* Drawing utility functions for the animation framework
* These are pure functions used by shapes for rendering
*/
/**
* Convert degrees to radians
* @param {number} degrees - Angle in degrees
* @returns {number} Angle in radians
*/
function rad(degrees) {
return (degrees * Math.PI) / 180;
}
/**
* Convert RGB array to CSS color string
* @param {number[]} colour - Array of [r, g, b] values
* @returns {string} CSS color string
*/
function colourToText(colour) {
return "rgb(" + colour[0] + "," + colour[1] + "," + colour[2] + ")";
}
/**
* Convert hex color to RGB object
* @param {string} hex - Hex color string (e.g., '#ff0000')
* @returns {{r: number, g: number, b: number}|null} RGB object or null
*/
function hexToRgb(hex) {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result ? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16)
} : null;
}
/**
* Linear interpolation between two hex colors
* @param {string} a - Start hex color
* @param {string} b - End hex color
* @param {number} amount - Interpolation amount (0-1)
* @returns {string} Interpolated hex color
*/
function LerpHex(a, b, amount) {
const ah = parseInt(a.replace(/#/g, ""), 16);
const ar = ah >> 16;
const ag = (ah >> 8) & 0xff;
const ab = ah & 0xff;
const bh = parseInt(b.replace(/#/g, ""), 16);
const br = bh >> 16;
const bg = (bh >> 8) & 0xff;
const bb = bh & 0xff;
const rr = ar + amount * (br - ar);
const rg = ag + amount * (bg - ag);
const rb = ab + amount * (bb - ab);
return "#" + (((1 << 24) + (rr << 16) + (rg << 8) + rb) | 0).toString(16).slice(1);
}
/**
* Linear interpolation between two RGB arrays
* @param {number[]} a - Start RGB array [r, g, b]
* @param {number[]} b - End RGB array [r, g, b]
* @param {number} t - Interpolation amount (0-1)
* @returns {number[]} Interpolated RGB array
*/
function lerpRGB(a, b, t) {
const result = [0, 0, 0];
for (let i = 0; i < 3; i++) {
result[i] = (1 - t) * a[i] + t * b[i];
}
return result;
}
/**
* Legacy LerpRGB function (handles negative t)
* @param {number[]} a - Start RGB array
* @param {number[]} b - End RGB array
* @param {number} t - Interpolation amount
* @returns {number[]} Interpolated RGB array
*/
function LerpRGB(a, b, t) {
if (t < 0) {
t *= -1;
}
const newColor = [0, 0, 0];
newColor[0] = a[0] + (b[0] - a[0]) * t;
newColor[1] = a[1] + (b[1] - a[1]) * t;
newColor[2] = a[2] + (b[2] - a[2]) * t;
return newColor;
}
/**
* Generate a wave-normalized value (0-1 range using sine)
* @param {number} x - Current position
* @param {number} max - Maximum position
* @returns {number} Normalized value (0-1)
*/
function waveNormal(x, max) {
return Math.sin((x / max) * Math.PI * 2 - max * (Math.PI / (max * 2))) / 2 + 0.5;
}
/**
* Rotate a point around origin
* @param {number} x - X coordinate
* @param {number} y - Y coordinate
* @param {number} rotation - Rotation angle in degrees
* @returns {number[]} Rotated [x, y] coordinates
*/
function rotatePoint(x, y, rotation) {
const nCos = Math.cos(rad(rotation));
const nSin = Math.sin(rad(rotation));
const newX = x * nCos - y * nSin;
const newY = y * nCos + x * nSin;
return [newX, newY];
}
/**
* Draw a regular polygon
* @param {number} sides - Number of sides
* @param {number} width - Radius of polygon
* @param {number} rotation - Rotation in degrees
* @param {string} colour - Stroke color
* @param {number} line_width - Line width
*/
function DrawPolygon(sides, width, rotation, colour, line_width) {
ctx.beginPath();
ctx.moveTo(
centerX + width * Math.cos((rotation * Math.PI) / 180),
centerY + width * Math.sin((rotation * Math.PI) / 180)
);
for (let i = 1; i <= sides; i += 1) {
ctx.lineTo(
centerX + width * Math.cos((i * 2 * Math.PI) / sides + (rotation * Math.PI) / 180),
centerY + width * Math.sin((i * 2 * Math.PI) / sides + (rotation * Math.PI) / 180)
);
}
ctx.strokeStyle = colour;
ctx.lineWidth = line_width;
ctx.stroke();
}
/**
* Draw an eyelid shape
* @param {number} width - Width of the eyelid
* @param {number} x1 - X position
* @param {number} y1 - Y position
* @param {string} colour - Fill color
*/
function drawEyelid(width, x1, y1, colour) {
x1 -= centerX;
y1 -= centerY;
const angle = Math.atan2(y1, x1);
const cosAngle = Math.cos(angle);
const sinAngle = Math.sin(angle);
const x2 = cosAngle * width;
const y2 = sinAngle * width;
const x3Old = width / 2;
const y3Old = width / 2;
const x4Old = width / 2;
const y4Old = -width / 2;
const x3 = x3Old * cosAngle - y3Old * sinAngle;
const y3 = x3Old * sinAngle + y3Old * cosAngle;
const x4 = x4Old * cosAngle - y4Old * sinAngle;
const y4 = x4Old * sinAngle + y4Old * cosAngle;
x1 += centerX;
y1 += centerY;
const x2Final = x2 + x1;
const y2Final = y2 + y1;
const x3Final = x3 + x1;
const y3Final = y3 + y1;
const x4Final = x4 + x1;
const y4Final = y4 + y1;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x3Final, y3Final, x2Final, y2Final);
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x4Final, y4Final, x2Final, y2Final);
ctx.fillStyle = colour;
ctx.fill();
ctx.lineWidth = 2;
ctx.strokeStyle = "black";
ctx.stroke();
}
/**
* Draw an accident-style eyelid shape
* @param {number} x1 - X position
* @param {number} y1 - Y position
*/
function drawEyelidAccident(x1, y1) {
const leafWidth = 120;
const leafHeight = 60;
x1 -= centerX;
y1 -= centerY;
let angle = Math.atan(y1 / x1);
angle = Math.abs(angle);
const x2Old = leafWidth;
const y2Old = 0;
const x3Old = leafWidth / 2;
const y3Old = leafHeight / 2;
const x4Old = leafWidth / 2;
const y4Old = -leafHeight / 2;
const x2 = x2Old * Math.cos(angle) - y2Old * Math.sin(angle);
const y2 = x2Old * Math.sin(angle) + y2Old * Math.cos(angle);
const x3 = x3Old * Math.cos(angle) - y3Old * Math.sin(angle);
const y3 = x3Old * Math.sin(angle) + y3Old * Math.cos(angle);
const x4 = x4Old * Math.cos(angle) - y4Old * Math.sin(angle);
const y4 = x4Old * Math.sin(angle) + y4Old * Math.cos(angle);
x1 += centerX;
y1 += centerY;
const x2f = x2 + x1;
const y2f = y2 + y1;
const x3f = x3 + x1;
const y3f = y3 + y1;
const x4f = x4 + x1;
const y4f = y4 + y1;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x3f, y3f, x2f, y2f);
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x4f, y4f, x2f, y2f);
ctx.fillStyle = "black";
ctx.fill();
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x3f, y3f, x2f, y2f);
ctx.moveTo(x1, y1);
ctx.quadraticCurveTo(x4f, y4f, x2f, y2f);
ctx.strokeStyle = "orange";
ctx.stroke();
}
/**
* Clear the canvas and fill with black
*/
function render_clear() {
ctx.clearRect(0, 0, ctx.canvas.width, ctx.canvas.height);
ctx.fillStyle = "black";
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
}
/**
* Draw crosshairs at center (for debugging)
* @param {number} width - Length of crosshair lines
*/
function drawCenter(width) {
ctx.strokeStyle = "pink";
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(centerX - width, centerY);
ctx.lineTo(centerX + width, centerY);
ctx.closePath();
ctx.stroke();
ctx.beginPath();
ctx.moveTo(centerX, centerY - width);
ctx.lineTo(centerX, centerY + width);
ctx.closePath();
ctx.stroke();
}
/**
* Update a control input's displayed value
* @param {number} value - New value
* @param {string} controlName - Name of the control property
*/
function updateControlInput(value, controlName) {
const elementSlider = document.querySelector('input[type="range"][id="el' + controlName + '"]');
if (elementSlider) {
elementSlider.value = value;
const elementSliderText = document.getElementById(`elText${controlName}`);
if (elementSliderText) {
elementSliderText.innerText = `${controlName}: ${Math.round(value)}`;
}
}
}