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

@@ -23,13 +23,13 @@ canvas {
display: flex;
flex-flow: column;
height: 100%;
position: absolute;
padding: 0px 20px 0px 20px;
width: 500px;
height: 100vh;
background-color: rgba(32, 32, 32, 0.616);
overflow-y: scroll;
box-sizing: border-box;
}
@media screen and (max-width: 768px) {
#toolbar {
@@ -38,43 +38,11 @@ canvas {
}
}
#shape-controls {
display: flex;
flex-flow: column;
height: 100%;
/* position: absolute; */
padding: 0px 20px 0px 20px;
/* width: 500px; */
/* height: 100vh; */
/* background-color: rgba(32, 32, 32, 0.616); */
}
#shape-controls p{
color: #e0e0e0;
font-family: Roboto, system-ui;
}
#toolbar p{
color: #e0e0e0;
font-family: Roboto, system-ui;
}
.control-container {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0px 0px 0px 0px;
}
.filter-div {
display: flex;
flex-direction: column;
justify-content: center;
margin: 0px 0px 0px 24px;
}
.header {
text-align: center;
font-weight: bold;
@@ -149,4 +117,820 @@ canvas {
.buttonReset{
background-color: #f4e1e1;
}
/* ============================================
Control System Styles
============================================ */
.control-container {
display: flex;
flex-direction: column;
margin: 2px 0;
padding: 6px 8px;
background: rgba(40, 45, 50, 0.5);
border-radius: 4px;
}
.control-range-container {
padding-bottom: 4px;
}
.control-main-row {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 4px;
}
.control-label-inline {
color: #b0b8c0;
font-family: Roboto, system-ui;
font-size: 12px;
flex: 1;
min-width: 80px;
}
.control-value {
color: #4a9eff;
font-family: 'Roboto Mono', monospace;
font-size: 12px;
min-width: 50px;
text-align: right;
}
.control-label {
color: #e0e0e0;
font-family: Roboto, system-ui;
font-size: 13px;
margin-bottom: 2px;
}
.control-range {
width: 100%;
cursor: pointer;
height: 6px;
-webkit-appearance: none;
appearance: none;
background: #3a4a5a;
border-radius: 3px;
outline: none;
}
.control-range::-webkit-slider-thumb {
-webkit-appearance: none;
width: 14px;
height: 14px;
background: #4a9eff;
border-radius: 50%;
cursor: pointer;
}
.control-range::-moz-range-thumb {
width: 14px;
height: 14px;
background: #4a9eff;
border-radius: 50%;
cursor: pointer;
border: none;
}
.control-color {
width: 40px;
height: 24px;
border: none;
cursor: pointer;
border-radius: 3px;
padding: 0;
}
.control-checkbox {
width: 16px;
height: 16px;
cursor: pointer;
accent-color: #4a9eff;
}
.control-dropdown {
padding: 4px 8px;
background: #2a2a2a;
color: #e0e0e0;
border: 1px solid #555;
border-radius: 3px;
}
/* Options row with toggle buttons */
.control-options-row {
display: flex;
gap: 4px;
margin-top: 4px;
}
.control-toggle-btn {
background: transparent;
border: 1px solid #3a4a5a;
border-radius: 3px;
color: #6a7a8a;
font-size: 10px;
padding: 2px 6px;
cursor: pointer;
transition: all 0.15s ease;
}
.control-toggle-btn:hover {
background: #3a4a5a;
color: #a0b0c0;
}
.control-toggle-btn.active {
background: #4a9eff20;
border-color: #4a9eff;
color: #4a9eff;
}
.control-toggle-btn.has-filters {
background: #4a9eff30;
border-color: #4a9eff;
color: #4a9eff;
}
/* Settings panel */
.control-settings-panel {
margin-top: 8px;
padding: 8px;
background: rgba(30, 35, 40, 0.6);
border-radius: 4px;
border-left: 2px solid #5a6a7a;
}
.setting-row {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
}
.setting-label {
color: #8a9aa0;
font-size: 11px;
min-width: 35px;
}
.setting-input {
background: #2a3a4a;
border: 1px solid #4a5a6a;
border-radius: 3px;
color: #e0e0e0;
padding: 3px 6px;
font-size: 11px;
width: 70px;
}
/* Filters panel */
.control-filters-panel {
margin-top: 8px;
}
.add-filter-row {
margin-bottom: 8px;
}
.filter-type-select {
width: 100%;
background: #2a3a4a;
border: 1px dashed #4a5a6a;
border-radius: 3px;
color: #8a9aa0;
padding: 6px 8px;
font-size: 11px;
cursor: pointer;
}
.filter-type-select:hover {
border-color: #6a7a8a;
color: #a0b0c0;
}
.filters-list {
display: flex;
flex-direction: column;
gap: 6px;
}
/* Individual filter item */
.filter-item {
background: rgba(40, 50, 60, 0.5);
border-left: 3px solid #4a9eff;
border-radius: 0 4px 4px 0;
padding: 6px 8px;
}
.filter-item-header {
display: flex;
align-items: center;
gap: 6px;
}
.filter-type-dropdown {
flex: 1;
background: #2a3a4a;
border: 1px solid #4a5a6a;
border-radius: 3px;
color: #8ac4ff;
padding: 4px 6px;
font-size: 11px;
cursor: pointer;
}
.filter-settings-toggle {
background: transparent;
border: 1px solid #3a4a5a;
border-radius: 3px;
color: #6a7a8a;
font-size: 10px;
padding: 2px 5px;
cursor: pointer;
}
.filter-settings-toggle:hover,
.filter-settings-toggle.active {
background: #3a4a5a;
color: #a0b0c0;
}
.filter-remove-btn {
background: #5a3a3a;
border: none;
border-radius: 3px;
color: #ff8a8a;
font-size: 12px;
width: 20px;
height: 20px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.filter-remove-btn:hover {
background: #7a4a4a;
color: #ffaaaa;
}
/* Filter settings panel */
.filter-settings-panel {
margin-top: 8px;
padding-left: 4px;
}
.filter-param-row {
margin: 6px 0;
}
.filter-param-top-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 3px;
}
.filter-param-label {
color: #8a9aa0;
font-size: 10px;
flex: 1;
}
.filter-param-value {
color: #6a9aff;
font-family: 'Roboto Mono', monospace;
font-size: 10px;
min-width: 35px;
text-align: right;
}
.param-settings-btn {
background: transparent;
border: none;
color: #5a6a7a;
font-size: 12px;
padding: 0 4px;
cursor: pointer;
}
.param-settings-btn:hover {
color: #8a9aaa;
}
.filter-param-slider {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: #3a4a5a;
border-radius: 2px;
outline: none;
}
.filter-param-slider::-webkit-slider-thumb {
-webkit-appearance: none;
width: 10px;
height: 10px;
background: #6a9aff;
border-radius: 50%;
cursor: pointer;
}
.filter-param-slider::-moz-range-thumb {
width: 10px;
height: 10px;
background: #6a9aff;
border-radius: 50%;
cursor: pointer;
border: none;
}
/* Parameter bounds panel */
.param-bounds-panel {
margin-top: 4px;
padding: 4px;
background: rgba(30, 40, 50, 0.5);
border-radius: 3px;
}
.param-bounds-row {
display: flex;
gap: 6px;
}
.param-bound-input {
flex: 1;
background: #2a3a4a;
border: 1px solid #4a5a6a;
border-radius: 3px;
color: #a0b0c0;
padding: 3px 6px;
font-size: 10px;
width: 50px;
}
/* ===== Scene / Multi-Shape UI ===== */
.scene-header {
display: flex;
flex-direction: column;
gap: 10px;
padding: 10px 0;
border-bottom: 1px solid #444;
margin-bottom: 10px;
}
.scene-header h3 {
margin: 0;
color: #e0e0e0;
font-size: 14px;
}
.scene-header-buttons {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.scene-btn {
padding: 6px 10px;
font-size: 11px;
background: #3a3a3a;
border: 1px solid #444;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
flex: 1;
min-width: 60px;
}
.scene-btn:hover {
background: #4a4a4a;
border-color: #4a9eff;
}
#add-shape-btn {
background: #4a9eff;
border-color: #4a9eff;
color: white;
}
#add-shape-btn:hover {
background: #3a8eef;
}
/* Legacy - keep for compatibility */
.add-shape-btn {
padding: 6px 12px;
font-size: 12px;
background: #4a9eff;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.add-shape-btn:hover {
background: #3a8eef;
}
/* Presets modal */
.presets-modal {
position: fixed;
padding: 0px 20px;
top: 0;
left: 0;
width: 500px;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
}
.presets-content {
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
width: calc(100% - 40px);
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
}
.presets-content h3 {
color: #e0e0e0;
margin: 0 0 16px 0;
text-align: center;
}
.presets-actions {
margin-bottom: 16px;
}
.preset-save-current-btn {
width: 100%;
padding: 10px;
font-size: 12px;
background: #4a9eff;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.preset-save-current-btn:hover {
background: #3a8eef;
}
.presets-list {
flex: 1;
overflow-y: auto;
min-height: 100px;
max-height: 300px;
margin-bottom: 16px;
}
.presets-empty {
color: #888;
text-align: center;
font-style: italic;
padding: 20px;
}
.preset-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 10px;
background: #3a3a3a;
border: 1px solid #444;
border-radius: 4px;
margin-bottom: 8px;
}
.preset-info {
display: flex;
flex-direction: column;
gap: 2px;
}
.preset-name {
color: #e0e0e0;
font-size: 13px;
font-weight: 500;
}
.preset-layers {
color: #888;
font-size: 11px;
}
.preset-actions {
display: flex;
gap: 6px;
}
.preset-load-btn {
padding: 6px 12px;
font-size: 11px;
background: #4a9eff;
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.preset-load-btn:hover {
background: #3a8eef;
}
.preset-delete-btn {
padding: 6px 8px;
font-size: 11px;
background: #444;
border: none;
border-radius: 4px;
color: #ccc;
cursor: pointer;
}
.preset-delete-btn:hover {
background: #c44;
color: white;
}
.presets-cancel {
width: 100%;
background: #555;
flex-shrink: 0;
}
.presets-cancel:hover {
background: #666;
}
.presets-section-header {
color: #888;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
padding: 12px 0 6px 0;
border-bottom: 1px solid #444;
margin-bottom: 8px;
}
.presets-section-header:first-child {
padding-top: 0;
}
.preset-builtin {
border-color: #4a9eff33;
}
.preset-description {
color: #888;
font-size: 11px;
font-style: italic;
}
@media screen and (max-width: 768px) {
.presets-modal {
width: 100%;
}
}
/* Layer panels */
.layer-panel {
background: #1a1a1a;
border: 1px solid #333;
border-radius: 6px;
margin-bottom: 10px;
overflow: hidden;
}
.layer-header {
display: flex;
align-items: center;
padding: 8px 10px;
background: #252525;
border-bottom: 1px solid #333;
gap: 8px;
}
.layer-collapse-btn {
cursor: pointer;
color: #888;
font-size: 10px;
width: 16px;
text-align: center;
}
.layer-collapse-btn:hover {
color: #fff;
}
.layer-name {
flex: 1;
color: #e0e0e0;
font-size: 13px;
font-weight: 500;
}
.layer-controls {
display: flex;
gap: 4px;
}
.layer-btn {
width: 24px;
height: 24px;
padding: 0;
font-size: 12px;
background: #333;
border: 1px solid #444;
border-radius: 4px;
color: #ccc;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.layer-btn:hover {
background: #444;
color: #fff;
}
.layer-remove:hover {
background: #c44;
border-color: #c44;
}
.layer-content {
padding: 10px;
}
/* Layers container */
#layers-container {
margin-top: 10px;
}
/* Shape picker modal */
.shape-picker-modal {
position: fixed;
top: 0;
left: 0;
width: 500px;
height: 100%;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
box-sizing: border-box;
}
.shape-picker-container {
background: #2a2a2a;
border-radius: 8px;
padding: 20px;
width: calc(100% - 40px);
max-height: 80vh;
display: flex;
flex-direction: column;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
box-sizing: border-box;
}
.shape-picker-container h3 {
color: #e0e0e0;
margin: 0 0 12px 0;
text-align: center;
flex-shrink: 0;
}
.shape-picker-tabs {
display: flex;
gap: 4px;
margin-bottom: 12px;
flex-shrink: 0;
}
.shape-picker-tab {
flex: 1;
padding: 8px 12px;
font-size: 12px;
background: #333;
border: 1px solid #444;
border-radius: 4px;
color: #aaa;
cursor: pointer;
transition: all 0.15s;
}
.shape-picker-tab:hover {
background: #3a3a3a;
color: #e0e0e0;
}
.shape-picker-tab.active {
background: #4a9eff;
border-color: #4a9eff;
color: white;
}
.shape-picker-content {
flex: 1;
min-height: 0;
overflow-y: auto;
}
.shape-picker-panel {
display: none;
}
.shape-picker-panel.active {
display: block;
}
.shape-picker-section {
margin-bottom: 16px;
}
.shape-picker-section-title {
color: #888;
font-size: 11px;
text-transform: uppercase;
letter-spacing: 1px;
padding-bottom: 6px;
border-bottom: 1px solid #444;
margin-bottom: 8px;
}
.shape-picker-buttons {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.shape-picker-btn {
padding: 10px 12px;
font-size: 11px;
text-align: left;
background: #3a3a3a;
border: 1px solid #444;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
}
.shape-picker-btn:hover {
background: #4a4a4a;
border-color: #4a9eff;
}
.shape-picker-btn.preset-btn {
display: flex;
flex-direction: column;
gap: 2px;
}
.preset-btn-name {
font-weight: 500;
}
.preset-btn-info {
font-size: 10px;
color: #888;
}
.shape-picker-cancel {
width: 100%;
padding: 10px;
margin-top: 12px;
font-size: 12px;
background: #555;
border: none;
border-radius: 4px;
color: #e0e0e0;
cursor: pointer;
flex-shrink: 0;
}
.shape-picker-cancel:hover {
background: #666;
}
/* Mobile responsive for shape picker */
@media screen and (max-width: 768px) {
.shape-picker-modal {
width: 100%;
}
}

View File

@@ -10,24 +10,8 @@
<body style="margin:0;">
<canvas id="myCanvas" style="display: block;box-sizing: border-box;"></canvas>
<div id="toolbar">
<br>
<select id="shape-selector">
<option value="RaysInShape">Rays</option>
<option value="Countdown">Countdown</option>
<option value="NewWave">NewWave</option>
<option value="EyePrototype">EyePrototype</option>
<option value="Nodal_expanding">Nodal_expanding</option>
<option value="MaryFace">MaryFace</option>
<option value="CircleExpand">CircleExpand</option>
<option value="PolyTwistColourWidth">PolyTwistColourWidth</option>
<option value="FloralPhyllo">FloralPhyllo</option>
<option value="FloralPhyllo_Accident">FloralPhyllo_Accident</option>
<option value="Spiral1">Spiral1</option>
<option value="FloralAccident">FloralAccident</option>
<option value="Phyllotaxis">Phyllotaxis</option>
<option value="SquareTwist_angle">SquareTwist_angle</option>
</select>
<div id="shape-controls"></div>
<!-- Shape layers -->
<div id="layers-container"></div>
<br>
<p>Controls:</p>
<p>
@@ -52,10 +36,40 @@
<button onclick="manualToggleSettings()" class="button">Show/hide</button>
</div>
</body>
<script src="./js/helper.js" type="text/javascript"></script>
<script src="https://code.jquery.com/jquery-3.6.0.min.js"></script>
<!-- Utilities (must load first - used by everything) -->
<script src="./js/utils/helpers.js" type="text/javascript"></script>
<script src="./js/math.js" type="text/javascript"></script>
<script src="./js/objects.js" type="text/javascript"></script>
<script src="./js/index.js" ></script>
<!-- Core modules -->
<script src="./js/core/ShapeRegistry.js" type="text/javascript"></script>
<script src="./js/core/AnimationEngine.js" type="text/javascript"></script>
<script src="./js/core/Scene.js" type="text/javascript"></script>
<script src="./js/core/BaseShape.js" type="text/javascript"></script>
<!-- Control system -->
<script src="./js/controls/FilterManager.js" type="text/javascript"></script>
<script src="./js/controls/ControlFactory.js" type="text/javascript"></script>
<script src="./js/controls/ControlManager.js" type="text/javascript"></script>
<script src="./js/presets.js" type="text/javascript"></script>
<script src="./js/controls/SceneUI.js" type="text/javascript"></script>
<!-- Shape definitions -->
<script src="./js/shapes/PolyTwistColourWidth.js" type="text/javascript"></script>
<script src="./js/shapes/FloralPhyllo.js" type="text/javascript"></script>
<script src="./js/shapes/Spiral1.js" type="text/javascript"></script>
<script src="./js/shapes/FloralAccident.js" type="text/javascript"></script>
<script src="./js/shapes/FloralPhyllo_Accident.js" type="text/javascript"></script>
<script src="./js/shapes/Nodal_expanding.js" type="text/javascript"></script>
<script src="./js/shapes/Phyllotaxis.js" type="text/javascript"></script>
<script src="./js/shapes/SquareTwist_angle.js" type="text/javascript"></script>
<script src="./js/shapes/CircleExpand.js" type="text/javascript"></script>
<script src="./js/shapes/EyePrototype.js" type="text/javascript"></script>
<script src="./js/shapes/MaryFace.js" type="text/javascript"></script>
<script src="./js/shapes/NewWave.js" type="text/javascript"></script>
<script src="./js/shapes/Countdown.js" type="text/javascript"></script>
<script src="./js/shapes/RaysInShape.js" type="text/javascript"></script>
<!-- Main application -->
<script src="./js/index.js" type="text/javascript"></script>
</html>

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)}`;
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,41 +0,0 @@
{
"name": "animate-react",
"version": "0.1.0",
"private": true,
"dependencies": {
"@react-three/drei": "^9.92.7",
"@react-three/fiber": "^8.15.12",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"three": "^0.159.0",
"react-colorful": "^5.6.1",
"zustand": "^4.4.7"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": [
"react-app",
"react-app/jest"
]
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
},
"devDependencies": {
"react-scripts": "5.0.1"
}
}

View File

@@ -1,18 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Animate - Ported to React and Three.js"
/>
<title>Animate - React</title>
</head>
<body>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
</body>
</html>

View File

@@ -1,54 +0,0 @@
import React, { useState, useEffect } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls } from '@react-three/drei';
import Toolbar from './components/Toolbar';
import AnimationScene from './components/AnimationScene';
import useAnimationStore from './store/animationStore';
function App() {
const [showToolbar, setShowToolbar] = useState(true);
const { selectedAnimation, setSelectedAnimation, animations } = useAnimationStore();
useEffect(() => {
const handleKeyDown = (e) => {
if (e.key === 'p' || e.key === 'P') {
setShowToolbar(prev => !prev);
}
};
window.addEventListener('keydown', handleKeyDown);
return () => {
window.removeEventListener('keydown', handleKeyDown);
};
}, []);
return (
<div className="App">
<Canvas
style={{ background: '#000', width: '100%', height: '100vh', position: 'absolute' }}
camera={{ position: [0, 0, 5], fov: 75 }}
dpr={[1, 2]}
>
<ambientLight intensity={0.5} />
<AnimationScene />
<OrbitControls />
</Canvas>
<button
className="toggle-button"
onClick={() => setShowToolbar(prev => !prev)}
>
{showToolbar ? 'Hide' : 'Show'} Controls
</button>
<Toolbar
isVisible={showToolbar}
selectedAnimation={selectedAnimation}
onAnimationChange={setSelectedAnimation}
animations={animations}
/>
</div>
);
}
export default App;

View File

@@ -1,132 +0,0 @@
import React, { useRef, useEffect } from 'react';
import { useFrame, useThree } from '@react-three/fiber';
import * as THREE from 'three';
import useAnimationStore from '../store/animationStore';
import { PolyTwistColourWidth } from './animations/PolyTwistColourWidth';
import { FloralPhyllo } from './animations/FloralPhyllo';
import { RaysInShape } from './animations/RaysInShape';
const AnimationScene = () => {
const { selectedAnimation, elapsedTime, rotation, updateAnimation, animations, paused } = useAnimationStore();
const lastTimeRef = useRef(0);
const sceneRef = useRef();
const objectsToDisposeRef = useRef([]);
const { viewport, size } = useThree();
// Helper function to properly dispose of Three.js objects
const disposeObjects = () => {
if (objectsToDisposeRef.current.length > 0) {
objectsToDisposeRef.current.forEach(object => {
// Dispose of geometries
if (object.geometry) {
object.geometry.dispose();
}
// Dispose of materials (could be an array or a single material)
if (object.material) {
if (Array.isArray(object.material)) {
object.material.forEach(material => material.dispose());
} else {
object.material.dispose();
}
}
});
// Clear the disposal list
objectsToDisposeRef.current = [];
}
};
// Clear scene when animation changes
useEffect(() => {
if (sceneRef.current) {
// Dispose of all existing objects first
disposeObjects();
while (sceneRef.current.children.length > 0) {
const child = sceneRef.current.children[0];
sceneRef.current.remove(child);
}
}
}, [selectedAnimation]);
// Cleanup on component unmount
useEffect(() => {
return () => {
disposeObjects();
};
}, []);
// Update animation on each frame
useFrame((state, delta) => {
if (!paused) {
updateAnimation(delta);
}
// Get the current animation parameters
const animationParams = animations[selectedAnimation].parameters;
// Get elapsed time in seconds
const currentTime = elapsedTime;
const deltaTime = currentTime - lastTimeRef.current;
lastTimeRef.current = currentTime;
// Clear the scene for a new frame
if (sceneRef.current) {
// Dispose of previous objects first to prevent memory leaks
disposeObjects();
while (sceneRef.current.children.length > 0) {
const child = sceneRef.current.children[0];
sceneRef.current.remove(child);
}
// Array to collect objects created in this frame for later disposal
const newObjects = [];
// Render the selected animation
switch (selectedAnimation) {
case 'PolyTwistColourWidth':
PolyTwistColourWidth({
scene: sceneRef.current,
rotation,
params: animationParams,
viewport,
objectsToDispose: newObjects
});
break;
case 'FloralPhyllo':
FloralPhyllo({
scene: sceneRef.current,
rotation,
params: animationParams,
viewport,
objectsToDispose: newObjects
});
break;
case 'RaysInShape':
RaysInShape({
scene: sceneRef.current,
elapsedTime: currentTime,
deltaTime,
params: animationParams,
viewport,
objectsToDispose: newObjects
});
break;
default:
// Default: render nothing
break;
}
// Store objects for disposal in the next frame
objectsToDisposeRef.current = newObjects;
}
});
return (
<group ref={sceneRef} />
);
};
export default AnimationScene;

View File

@@ -1,67 +0,0 @@
import React from 'react';
import useAnimationStore from '../store/animationStore';
const ControlFilter = ({ filter, property, index }) => {
const { updateFilter, removeFilter } = useAnimationStore();
const handleFilterChange = (filterProperty, value) => {
updateFilter(property, index, filterProperty, value);
};
return (
<div className="filter">
<div className="filter-header">
Filter {index + 1}: {filter.min} to {filter.max} @ {filter.frequency.toFixed(2)}Hz
</div>
<div className="control-row">
<label>Min:</label>
<input
type="range"
className="range-control"
value={filter.min}
min={-200}
max={filter.max}
onChange={(e) => handleFilterChange('min', parseFloat(e.target.value))}
/>
<span className="value">{Math.round(filter.min)}</span>
</div>
<div className="control-row">
<label>Max:</label>
<input
type="range"
className="range-control"
value={filter.max}
min={filter.min}
max={200}
onChange={(e) => handleFilterChange('max', parseFloat(e.target.value))}
/>
<span className="value">{Math.round(filter.max)}</span>
</div>
<div className="control-row">
<label>Frequency:</label>
<input
type="range"
className="range-control"
value={filter.frequency * 100}
min={1}
max={200}
onChange={(e) => handleFilterChange('frequency', parseFloat(e.target.value) / 100)}
/>
<span className="value">{filter.frequency.toFixed(2)}Hz</span>
</div>
<button
className="button button-reset"
style={{ marginTop: '5px', fontSize: '11px', padding: '3px 8px' }}
onClick={() => removeFilter(property, index)}
>
Remove Filter
</button>
</div>
);
};
export default ControlFilter;

View File

@@ -1,177 +0,0 @@
import React from 'react';
import { HexColorPicker } from 'react-colorful';
import useAnimationStore from '../store/animationStore';
import ControlFilter from './ControlFilter';
const Toolbar = ({ isVisible }) => {
const {
selectedAnimation,
setSelectedAnimation,
animations,
updateParameter,
addFilter,
paused,
togglePause,
resetAnimation,
speedMultiplier,
} = useAnimationStore();
const currentAnimation = animations[selectedAnimation];
const handleAnimationChange = (e) => {
setSelectedAnimation(e.target.value);
};
const handleParameterChange = (property, value) => {
updateParameter(property, value);
};
const renderControl = (control) => {
const value = currentAnimation.parameters[control.property];
switch (control.type) {
case 'range':
return (
<div className="control-row" key={control.property}>
<label htmlFor={`control-${control.property}`}>{control.label || control.property}:</label>
<input
id={`control-${control.property}`}
type="range"
min={control.min}
max={control.max}
step={1}
value={value}
onChange={(e) => handleParameterChange(control.property, parseFloat(e.target.value))}
className="range-control"
/>
<span className="value">{Math.round(value * 100) / 100}</span>
<button
className="add-filter-button"
onClick={() => addFilter(control.property)}
>
+
</button>
</div>
);
case 'color':
return (
<div className="control-container" key={control.property}>
<label htmlFor={`control-${control.property}`}>{control.label || control.property}:</label>
<div style={{ margin: '10px 0' }}>
<div
style={{
width: '100%',
height: '20px',
background: value,
marginBottom: '5px',
border: '1px solid #444'
}}
/>
<HexColorPicker
color={value}
onChange={(color) => handleParameterChange(control.property, color)}
/>
</div>
</div>
);
case 'checkbox':
return (
<div className="control-row" key={control.property}>
<label htmlFor={`control-${control.property}`}>{control.label || control.property}:</label>
<input
id={`control-${control.property}`}
type="checkbox"
checked={value}
onChange={(e) => handleParameterChange(control.property, e.target.checked)}
/>
</div>
);
default:
return null;
}
};
const renderFilters = (property) => {
const filters = currentAnimation.filters[property];
if (!filters || filters.length === 0) return null;
return (
<div className="filter-container">
<div className="filter-header">Filters</div>
{filters.map((filter, index) => (
<ControlFilter
key={`${property}-filter-${index}`}
filter={filter}
property={property}
index={index}
/>
))}
</div>
);
};
return (
<div className={`toolbar ${isVisible ? '' : 'hidden'}`}>
<h2>Animation Controls</h2>
<div className="control-container">
<label htmlFor="animation-selector">Animation:</label>
<select
id="animation-selector"
value={selectedAnimation}
onChange={handleAnimationChange}
className="shape-selector"
>
{Object.keys(animations).map(animName => (
<option key={animName} value={animName}>{animName}</option>
))}
</select>
</div>
<div className="control-container">
<button
className="button"
onClick={togglePause}
>
{paused ? 'Play' : 'Pause'}
</button>
<button
className="button button-reset"
onClick={resetAnimation}
>
Reset
</button>
</div>
<div className="control-row">
<label htmlFor="speed-multiplier">Speed:</label>
<input
id="speed-multiplier"
type="range"
min={1}
max={500}
value={speedMultiplier}
onChange={(e) => useAnimationStore.setState({ speedMultiplier: parseInt(e.target.value) })}
className="range-control"
/>
<span className="value">{speedMultiplier}</span>
</div>
<hr style={{ margin: '20px 0' }} />
<h3>Parameters</h3>
{currentAnimation.config.map(control => (
<React.Fragment key={control.property}>
{renderControl(control)}
{renderFilters(control.property)}
</React.Fragment>
))}
</div>
);
};
export default Toolbar;

View File

@@ -1,96 +0,0 @@
import * as THREE from 'three';
export const FloralPhyllo = ({ scene, rotation, params, viewport, objectsToDispose = [] }) => {
const {
width,
depth,
start,
colour1,
colour2
} = params;
// The effective rotation with start offset
const effectiveRotation = rotation + start;
// Generate points in phyllotaxis pattern
for (let n = 1; n <= depth; n++) {
// Calculate position using phyllotaxis formula
// Use the golden angle (137.5 degrees) for natural-looking pattern
const a = n * 0.1 + effectiveRotation / 100;
const r = Math.sqrt(n) * (width / 25);
const x = r * Math.cos(a);
const y = r * Math.sin(a);
// Calculate color using gradient based on position in the sequence
const colorFraction = n / depth;
const color = lerpColor(colour1, colour2, colorFraction);
// Create a petal/eye shape at this position
createPetal(scene, n/2 + 10, x, y, 0, color, objectsToDispose);
}
};
// Helper function to create a petal/eye shape
function createPetal(scene, size, x, y, z, color, objectsToDispose) {
// Create a custom shape for the petal/eye
const shape = new THREE.Shape();
// Define control points for the petal shape
const halfSize = size / 2;
// Starting point
shape.moveTo(-halfSize, 0);
// Top curve
shape.quadraticCurveTo(0, halfSize, halfSize, 0);
// Bottom curve
shape.quadraticCurveTo(0, -halfSize, -halfSize, 0);
// Create geometry from shape
const geometry = new THREE.ShapeGeometry(shape);
// Create material with specified color
const material = new THREE.MeshBasicMaterial({
color: new THREE.Color(color),
side: THREE.DoubleSide
});
// Create mesh and position it
const petal = new THREE.Mesh(geometry, material);
petal.position.set(x, y, z);
// Calculate angle based on position relative to origin
const angle = Math.atan2(y, x);
petal.rotation.z = angle; // Rotate to face outward
// Add stroke outline
const edgesGeometry = new THREE.EdgesGeometry(geometry);
const edgesMaterial = new THREE.LineBasicMaterial({
color: new THREE.Color(0x000000),
linewidth: 1
});
const edges = new THREE.LineSegments(edgesGeometry, edgesMaterial);
petal.add(edges);
scene.add(petal);
// Add to objects to dispose list
if (objectsToDispose) {
objectsToDispose.push(petal);
// Also track the edges for disposal
objectsToDispose.push(edges);
}
}
// Convert hex color string to THREE.js color and interpolate
function lerpColor(colorA, colorB, t) {
const a = new THREE.Color(colorA);
const b = new THREE.Color(colorB);
return new THREE.Color(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t
).getHex();
}

View File

@@ -1,96 +0,0 @@
import * as THREE from 'three';
export const PolyTwistColourWidth = ({ scene, rotation, params, viewport, objectsToDispose = [] }) => {
const {
sides,
width,
lineWidth,
depth,
rotation: rotationParam,
colour1,
colour2
} = params;
// Calculate the inner angle of the polygon
const innerAngle = 180 - ((sides - 2) * 180) / sides;
const scopeAngle = rotation - (innerAngle * Math.floor(rotation / innerAngle));
// Calculate the twist angle
let outAngle = 0;
if (scopeAngle < innerAngle / 2) {
outAngle = innerAngle / (2 * Math.cos((2 * Math.PI * scopeAngle) / (3 * innerAngle))) - innerAngle / 2;
} else {
outAngle = -innerAngle / (2 * Math.cos(((2 * Math.PI) / 3) - ((2 * Math.PI * scopeAngle) / (3 * innerAngle)))) + (innerAngle * 3) / 2;
}
// Calculate width multiplier
const minWidth = Math.sin(toRadians(innerAngle / 2)) * (0.5 / Math.tan(toRadians(innerAngle / 2))) * 2;
const widthMultiplier = minWidth / Math.sin(Math.PI / 180 * (90 + innerAngle / 2 - outAngle + innerAngle * Math.floor(outAngle / innerAngle)));
// Draw each polygon with increasing size and color transition
for (let i = 0; i < depth; i++) {
const fraction = i / depth;
const color = lerpColor(colour1, colour2, fraction);
// Create a polygon shape
drawPolygon(
scene,
sides,
width * Math.pow(widthMultiplier, i),
outAngle * i + rotationParam,
color,
lineWidth,
objectsToDispose
);
}
};
// Helper functions
function drawPolygon(scene, sides, width, rotation, color, lineWidth, objectsToDispose) {
const points = [];
// Create points for the polygon
for (let i = 0; i <= sides; i++) {
const angle = (i * 2 * Math.PI) / sides + (rotation * Math.PI) / 180;
points.push(new THREE.Vector3(
width * Math.cos(angle),
width * Math.sin(angle),
0
));
}
// Create geometry from points
const geometry = new THREE.BufferGeometry().setFromPoints(points);
// Create material with specified color
const material = new THREE.LineBasicMaterial({
color: new THREE.Color("#" + color),
linewidth: lineWidth // Note: linewidth may not work as expected in WebGL
});
// Create and add the line
const line = new THREE.Line(geometry, material);
scene.add(line);
// Add to objects to dispose list
if (objectsToDispose) {
objectsToDispose.push(line);
}
}
// Convert hex color string to THREE.js color
function lerpColor(colorA, colorB, t) {
const a = new THREE.Color(colorA);
const b = new THREE.Color(colorB);
return new THREE.Color(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t
).getHexString();
}
// Convert degrees to radians
function toRadians(degrees) {
return degrees * Math.PI / 180;
}

View File

@@ -1,280 +0,0 @@
import * as THREE from 'three';
export const RaysInShape = ({ scene, elapsedTime, deltaTime, params, viewport, objectsToDispose = [] }) => {
const {
rays,
speed,
doesWave,
speedVertRate,
speedHorrRate,
speedVert,
speedHorr,
boxSize,
trailLength,
lineWidth,
fade,
colourFree,
colourContained,
boxVisible
} = params;
// Define box boundaries
const halfBoxSize = boxSize / 200;
const boxLeft = -halfBoxSize;
const boxRight = halfBoxSize;
const boxTop = -halfBoxSize;
const boxBottom = halfBoxSize;
// Draw the box if visible
if (boxVisible) {
const boxGeometry = new THREE.BufferGeometry();
const boxVertices = new Float32Array([
boxLeft, boxTop, 0,
boxRight, boxTop, 0,
boxRight, boxBottom, 0,
boxLeft, boxBottom, 0,
boxLeft, boxTop, 0
]);
boxGeometry.setAttribute('position', new THREE.BufferAttribute(boxVertices, 3));
const boxMaterial = new THREE.LineBasicMaterial({ color: 0xffffff });
const box = new THREE.Line(boxGeometry, boxMaterial);
scene.add(box);
// Track box for disposal
if (objectsToDispose) {
objectsToDispose.push(box);
}
}
// Calculate current wave values if waves are enabled
let currentSpeedVert = speedVert;
let currentSpeedHorr = speedHorr;
if (doesWave) {
const vertRate = speedVertRate / 1000;
const horrRate = speedHorrRate / 1000;
currentSpeedVert = Math.sin(elapsedTime * vertRate) * 85 + 100;
currentSpeedHorr = Math.sin(elapsedTime * horrRate) * 85 + 100;
}
// Generate rays
const actualRayCount = Math.min(rays, 100); // Limit for performance
for (let i = 0; i < actualRayCount; i++) {
const angle = (i * 360) / actualRayCount;
// Create ray trajectory
const positions = generateRayPositions(
0, 0, 0, // Start at center
angle,
speed / 10000,
currentSpeedVert / 100,
currentSpeedHorr / 100,
boxLeft,
boxRight,
boxTop,
boxBottom,
elapsedTime
);
// Draw the ray trail
drawRayTrail(
scene,
positions,
Math.min(trailLength, 30), // Limit trail length for performance
lineWidth / 10,
colourContained,
fade,
objectsToDispose
);
// Draw center-bound rays from collision points
positions.forEach(pos => {
if (pos.collision) {
const angleToCenter = Math.atan2(-pos.y, -pos.x) * 180 / Math.PI;
const centerTrail = generateCenterRayPositions(
pos.x, pos.y, 0,
angleToCenter,
speed / 8000,
Math.min(trailLength / 2, 15) // Limit trail length for performance
);
drawRayTrail(
scene,
centerTrail,
Math.min(trailLength / 2, 15),
lineWidth / 10,
colourFree,
fade,
objectsToDispose
);
}
});
}
};
// Generate positions for a ray
function generateRayPositions(
startX, startY, startZ,
angle,
speed,
speedVert,
speedHorr,
boxLeft,
boxRight,
boxTop,
boxBottom,
elapsedTime
) {
const positions = [];
let x = startX;
let y = startY;
let z = startZ;
let currentAngle = angle;
let collisionCount = 0;
const maxCollisions = 5; // Limit collisions for performance
// Calculate initial direction
const radians = (currentAngle * Math.PI) / 180;
let dirX = Math.cos(radians);
let dirY = Math.sin(radians);
// Add starting position
positions.push({ x, y, z, angle: currentAngle });
// Move ray until collision or max collisions reached
while (collisionCount < maxCollisions) {
// Calculate new position
const dx = speedHorr * speed * dirX;
const dy = speedVert * speed * dirY;
x += dx;
y += dy;
// Check for collisions
let collision = null;
// Check horizontal boundaries
if (x < boxLeft) {
x = boxLeft;
currentAngle = 180 - currentAngle;
collision = 'left';
} else if (x > boxRight) {
x = boxRight;
currentAngle = 180 - currentAngle;
collision = 'right';
}
// Check vertical boundaries
if (y < boxTop) {
y = boxTop;
currentAngle = 360 - currentAngle;
collision = 'top';
} else if (y > boxBottom) {
y = boxBottom;
currentAngle = 360 - currentAngle;
collision = 'bottom';
}
// Normalize angle
currentAngle = ((currentAngle % 360) + 360) % 360;
// Add position to array
positions.push({
x, y, z,
angle: currentAngle,
collision
});
// If collision occurred, update direction and increment counter
if (collision) {
const newRadians = (currentAngle * Math.PI) / 180;
dirX = Math.cos(newRadians);
dirY = Math.sin(newRadians);
collisionCount++;
}
// Break if we're outside the box
if (x < boxLeft * 2 || x > boxRight * 2 || y < boxTop * 2 || y > boxBottom * 2) {
break;
}
}
return positions;
}
// Generate center-bound ray positions
function generateCenterRayPositions(startX, startY, startZ, angle, speed, trailLength) {
const positions = [];
let x = startX;
let y = startY;
let z = startZ;
// Calculate direction toward center
const radians = (angle * Math.PI) / 180;
const dirX = Math.cos(radians);
const dirY = Math.sin(radians);
// Add starting position
positions.push({ x, y, z, angle });
// Generate trail points
for (let i = 0; i < trailLength; i++) {
// Move toward center
x += dirX * speed;
y += dirY * speed;
// Add to positions
positions.push({ x, y, z, angle });
// If very close to center, stop
if (Math.abs(x) < 0.01 && Math.abs(y) < 0.01) {
break;
}
}
return positions;
}
// Draw a ray trail
function drawRayTrail(scene, positions, maxPoints, lineWidth, color, fade, objectsToDispose) {
// Use only a portion of positions for performance
const usePositions = positions.slice(0, maxPoints);
// Create line segments for the ray
for (let i = 1; i < usePositions.length; i++) {
const prev = usePositions[i - 1];
const curr = usePositions[i];
// Calculate opacity based on position in trail if fade is enabled
let opacity = 1;
if (fade) {
opacity = 1 - (i / usePositions.length);
}
// Create geometry for line segment
const geometry = new THREE.BufferGeometry();
const vertices = new Float32Array([
prev.x, prev.y, prev.z,
curr.x, curr.y, curr.z
]);
geometry.setAttribute('position', new THREE.BufferAttribute(vertices, 3));
// Create material with appropriate color
const material = new THREE.LineBasicMaterial({
color: curr.collision ? 0xffff00 : new THREE.Color(color),
transparent: fade,
opacity: opacity,
linewidth: lineWidth
});
// Create and add the line
const line = new THREE.Line(geometry, material);
scene.add(line);
// Track line for disposal
if (objectsToDispose) {
objectsToDispose.push(line);
}
}
}

View File

@@ -1,173 +0,0 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
background-color: black;
color: white;
overflow: hidden;
height: 100%;
width: 100%;
}
html {
height: 100%;
width: 100%;
}
* {
box-sizing: border-box;
}
.App {
width: 100%;
height: 100vh;
position: relative;
}
canvas {
display: block;
width: 100% !important;
height: 100vh !important;
touch-action: none;
}
.toolbar {
position: absolute;
top: 0;
right: 0;
width: 300px;
height: 100vh;
background-color: rgba(32, 32, 32, 0.8);
padding: 20px;
overflow-y: auto;
color: #e0e0e0;
font-family: Roboto, system-ui;
transition: transform 0.3s ease;
z-index: 10;
}
.toolbar.hidden {
transform: translateX(100%);
}
.control-container {
margin-bottom: 15px;
}
.control-label {
margin-bottom: 5px;
display: block;
}
.shape-selector {
width: 100%;
padding: 8px;
margin-bottom: 20px;
background-color: #333;
color: white;
border: 1px solid #555;
}
.button {
display: inline-block;
background-color: #e1ecf4;
border-radius: 3px;
border: 1px solid #7aa7c7;
box-sizing: border-box;
color: #1f3f55;
cursor: pointer;
font-size: 13px;
font-weight: 400;
padding: 8px 12px;
text-align: center;
margin: 5px 2px;
}
.button:hover {
background-color: #b3d3ea;
}
.button-reset {
background-color: #f4e1e1;
}
.toggle-button {
position: absolute;
top: 10px;
right: 320px;
z-index: 11;
padding: 10px;
background-color: rgba(32, 32, 32, 0.8);
border: none;
border-radius: 4px;
color: white;
cursor: pointer;
}
.filter-container {
margin-left: 20px;
padding: 10px;
border-left: 1px solid #444;
margin-top: 10px;
}
.filter-header {
margin-bottom: 10px;
font-weight: bold;
}
.range-control {
width: 100%;
background: #444;
height: 5px;
border-radius: 5px;
outline: none;
transition: background 0.15s;
}
.range-control::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 15px;
height: 15px;
border-radius: 50%;
background: #e1ecf4;
cursor: pointer;
}
.add-filter-button {
margin-top: 5px;
padding: 3px 8px;
background-color: #444;
border: none;
color: white;
border-radius: 3px;
cursor: pointer;
font-size: 12px;
}
.add-filter-button:hover {
background-color: #555;
}
.control-row {
display: flex;
align-items: center;
margin-bottom: 5px;
}
.control-row label {
flex: 1;
}
.control-row input[type="range"] {
flex: 2;
}
.control-row .value {
flex: 0 0 40px;
text-align: right;
}

View File

@@ -1,12 +0,0 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import './index.css';
import App from './App';
const container = document.getElementById('root');
const root = createRoot(container);
root.render(
<React.StrictMode>
<App />
</React.StrictMode>
);

View File

@@ -1,295 +0,0 @@
import { create } from 'zustand';
// Animation definitions with their configurations
const animationConfigs = {
PolyTwistColourWidth: [
{ type: "range", min: 3, max: 10, defaultValue: 5, property: "sides", label: "Sides" },
{ type: "range", min: 400, max: 2000, defaultValue: 400, property: "width", label: "Width" },
{ type: "range", min: 2, max: 5, defaultValue: 5, property: "lineWidth", label: "Line Width" },
{ type: "range", min: 1, max: 100, defaultValue: 50, property: "depth", label: "Depth" },
{ type: "range", min: -180, max: 180, defaultValue: -90, property: "rotation", label: "Rotation" },
{ type: "range", min: 1, max: 500, defaultValue: 100, property: "speedMultiplier", label: "Speed" },
{ type: "color", defaultValue: "#4287f5", property: "colour1", label: "Color 1" },
{ type: "color", defaultValue: "#42f57b", property: "colour2", label: "Color 2" },
],
FloralPhyllo: [
{ type: "range", min: 1, max: 600, defaultValue: 300, property: "width", label: "Width" },
{ type: "range", min: 1, max: 300, defaultValue: 150, property: "depth", label: "Depth" },
{ type: "range", min: 0, max: 3141, defaultValue: 0, property: "start", label: "Start" },
{ type: "color", defaultValue: "#4287f5", property: "colour1", label: "Color 1" },
{ type: "color", defaultValue: "#FC0362", property: "colour2", label: "Color 2" },
],
RaysInShape: [
{ type: "range", min: 50, max: 1000, defaultValue: 500, property: "rays", label: "Rays" },
{ type: "range", min: 1, max: 30, defaultValue: 2, property: "speed", label: "Speed" },
{ type: "checkbox", defaultValue: true, property: "doesWave", label: "Wave Effect" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedVertRate", label: "Vertical Rate" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedHorrRate", label: "Horizontal Rate" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedVert", label: "Vertical Speed" },
{ type: "range", min: 1, max: 200, defaultValue: 100, property: "speedHorr", label: "Horizontal Speed" },
{ type: "range", min: 10, max: 2000, defaultValue: 800, property: "boxSize", label: "Box Size" },
{ type: "range", min: 1, max: 80, defaultValue: 5, property: "trailLength", label: "Trail Length" },
{ type: "range", min: 1, max: 500, defaultValue: 5, property: "lineWidth", label: "Line Width" },
{ type: "checkbox", defaultValue: false, property: "fade", label: "Fade Effect" },
{ type: "color", defaultValue: "#43dbad", property: "colourFree", label: "Free Color" },
{ type: "color", defaultValue: "#f05c79", property: "colourContained", label: "Contained Color" },
{ type: "checkbox", defaultValue: false, property: "boxVisible", label: "Show Box" },
]
};
// Generate default parameters for each animation
const generateDefaultParameters = (config) => {
const defaults = {};
config.forEach(item => {
defaults[item.property] = item.defaultValue;
});
return defaults;
};
// Helper function to create an animation state object
const createAnimationState = () => {
const animations = {};
Object.keys(animationConfigs).forEach(animName => {
animations[animName] = {
config: animationConfigs[animName],
parameters: generateDefaultParameters(animationConfigs[animName]),
baseParameters: generateDefaultParameters(animationConfigs[animName]), // Store original values
filters: {} // Will store filters applied to parameters
};
});
return animations;
};
// Create the store
const useAnimationStore = create((set, get) => ({
// Store all animation configurations and their parameters
animations: createAnimationState(),
// Currently selected animation
selectedAnimation: 'PolyTwistColourWidth',
// Animation control
paused: false,
elapsedTime: 0,
rotation: 0,
speedMultiplier: 100,
// Set the selected animation
setSelectedAnimation: (animationName) => {
set({ selectedAnimation: animationName });
},
// Toggle pause state
togglePause: () => {
set(state => ({ paused: !state.paused }));
},
// Reset the animation
resetAnimation: () => {
set({ rotation: 0, elapsedTime: 0 });
},
// Update a parameter for the current animation
updateParameter: (property, value) => {
set(state => {
const animName = state.selectedAnimation;
return {
animations: {
...state.animations,
[animName]: {
...state.animations[animName],
parameters: {
...state.animations[animName].parameters,
[property]: value
},
baseParameters: {
...state.animations[animName].baseParameters,
[property]: value
}
}
}
};
});
},
// Add a filter to a parameter
addFilter: (property, filterType = 'sine') => {
set(state => {
const animName = state.selectedAnimation;
const config = state.animations[animName].config.find(c => c.property === property);
if (!config) return state;
// Calculate default min and max based on the base parameter value
const baseValue = state.animations[animName].baseParameters[property];
const range = config.max - config.min;
const offset = range * 0.2; // 20% of range
// Create default filter with min/max values
const newFilter = {
type: filterType,
min: baseValue - offset,
max: baseValue + offset,
frequency: 0.3, // Hz - cycles per second
phase: Math.random() * Math.PI * 2, // Random phase offset between 0 and 2π
enabled: true
};
const currentFilters = state.animations[animName].filters[property] || [];
return {
animations: {
...state.animations,
[animName]: {
...state.animations[animName],
filters: {
...state.animations[animName].filters,
[property]: [...currentFilters, newFilter]
}
}
}
};
});
},
// Update a filter
updateFilter: (property, filterIndex, filterProperty, value) => {
set(state => {
const animName = state.selectedAnimation;
const filters = state.animations[animName].filters[property] || [];
if (filterIndex >= filters.length) return state;
const updatedFilters = [...filters];
updatedFilters[filterIndex] = {
...updatedFilters[filterIndex],
[filterProperty]: value
};
return {
animations: {
...state.animations,
[animName]: {
...state.animations[animName],
filters: {
...state.animations[animName].filters,
[property]: updatedFilters
}
}
}
};
});
},
// Remove a filter
removeFilter: (property, filterIndex) => {
set(state => {
const animName = state.selectedAnimation;
const filters = state.animations[animName].filters[property] || [];
if (filterIndex >= filters.length) return state;
const updatedFilters = filters.filter((_, i) => i !== filterIndex);
return {
animations: {
...state.animations,
[animName]: {
...state.animations[animName],
filters: {
...state.animations[animName].filters,
[property]: updatedFilters
}
}
}
};
});
},
// Update the animation state on each frame
updateAnimation: (deltaTime) => {
set(state => {
if (state.paused) return state;
const newElapsedTime = state.elapsedTime + deltaTime;
const newRotation = state.rotation + (deltaTime * state.speedMultiplier / 100);
// Apply filters to parameters
const animName = state.selectedAnimation;
const animation = state.animations[animName];
const updatedParameters = { ...animation.parameters };
// Process each parameter that has filters
Object.entries(animation.filters).forEach(([property, filters]) => {
if (!filters || filters.length === 0) return;
// Start with the base parameter value (unmodified by filters)
const baseValue = animation.baseParameters[property];
// Find the config for this property to get min/max bounds
const config = animation.config.find(c => c.property === property);
const propMin = config?.min;
const propMax = config?.max;
// Calculate the combined effect of all filters
let totalModification = 0;
// Apply each filter to build up the modifications
filters.forEach(filter => {
if (!filter.enabled) return;
if (filter.type === 'sine') {
// Calculate sine wave value based on time and filter properties
const frequency = filter.frequency || 0.3; // Hz
const phase = filter.phase || 0;
const min = filter.min || baseValue - 10;
const max = filter.max || baseValue + 10;
// Calculate center and amplitude from min/max
const center = (min + max) / 2;
const amplitude = (max - min) / 2;
// Create sinusoidal oscillation between min and max values
// const sineValue = center + Math.sin(newElapsedTime * 2 * Math.PI * frequency + phase) * amplitude;
const sineValue = min + amplitude + Math.sin(newElapsedTime * (1 / frequency)) * amplitude;
// Instead of direct modification, calculate the difference from base
const modification = sineValue;
console.log(`Filter: ${property}, Sine Value: ${sineValue}, Modification: ${modification}`);
// Add this filter's contribution to the total
totalModification += modification;
}
});
// Apply total modification to base value
// let filteredValue = baseValue + totalModification;
let filteredValue = totalModification;
// Constrain to the property's min/max if available
if (propMin !== undefined && filteredValue < propMin) filteredValue = propMin;
if (propMax !== undefined && filteredValue > propMax) filteredValue = propMax;
// Update the parameter with the filtered value
updatedParameters[property] = filteredValue;
});
return {
elapsedTime: newElapsedTime,
rotation: newRotation,
animations: {
...state.animations,
[animName]: {
...animation,
parameters: updatedParameters
}
}
};
});
}
}));
export default useAnimationStore;