|
<!DOCTYPE html> |
|
<html lang="nl"> |
|
<head> |
|
<meta charset="UTF-8"> |
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
<title>24/7 Circular TimeSlots Timer</title> |
|
<style> |
|
* { |
|
box-sizing: border-box; |
|
} |
|
|
|
body { |
|
margin: 0; |
|
background: #111827; |
|
color: #eee; |
|
font-family: sans-serif; |
|
padding: 20px; |
|
max-width: 500px; |
|
margin: 0 auto; |
|
} |
|
|
|
h1 { |
|
font-size: 18px; |
|
margin-bottom: 10px; |
|
text-align: center; |
|
} |
|
|
|
/* Day Navigation */ |
|
.day-navigation { |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
margin-bottom: 15px; |
|
gap: 10px; |
|
} |
|
|
|
.nav-btn { |
|
background: #374151; |
|
color: #eee; |
|
border: none; |
|
border-radius: 50%; |
|
width: 30px; |
|
height: 30px; |
|
cursor: pointer; |
|
font-size: 16px; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.day-slider { |
|
display: flex; |
|
overflow-x: auto; |
|
gap: 8px; |
|
padding: 10px 0; |
|
scrollbar-width: none; |
|
} |
|
|
|
.day-slider::-webkit-scrollbar { |
|
display: none; |
|
} |
|
|
|
.day-tab { |
|
padding: 8px 12px; |
|
background: #374151; |
|
border-radius: 8px; |
|
cursor: pointer; |
|
white-space: nowrap; |
|
flex-shrink: 0; |
|
} |
|
|
|
.day-tab.active { |
|
background: #06b6d4; |
|
color: #111; |
|
font-weight: bold; |
|
} |
|
|
|
.current-day-indicator { |
|
text-align: center; |
|
margin-bottom: 10px; |
|
font-size: 14px; |
|
color: #fbbf24; |
|
} |
|
|
|
/* Canvas Wrapper for Swiping */ |
|
.canvas-container-outer { |
|
position: relative; |
|
width: 320px; |
|
height: 320px; |
|
margin: 0 auto 20px auto; |
|
overflow: hidden; |
|
border-radius: 50%; |
|
} |
|
|
|
.canvas-wrapper { |
|
display: flex; |
|
width: 100%; |
|
height: 100%; |
|
overflow-x: scroll; |
|
scroll-snap-type: x mandatory; |
|
-webkit-overflow-scrolling: touch; |
|
scrollbar-width: none; |
|
background: #111; |
|
border-radius: 50%; |
|
} |
|
|
|
.canvas-wrapper::-webkit-scrollbar { |
|
display: none; |
|
} |
|
|
|
.day-canvas-item { |
|
flex-shrink: 0; |
|
width: 320px; |
|
height: 320px; |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
scroll-snap-align: center; |
|
position: relative; |
|
} |
|
|
|
.day-canvas-item canvas { |
|
width: 320px; |
|
height: 320px; |
|
border-radius: 50%; |
|
display: block; |
|
} |
|
|
|
/* Central overlay elements */ |
|
.circle-overlay { |
|
position: absolute; |
|
top: 50%; |
|
left: 50%; |
|
transform: translate(-50%, -50%); |
|
width: 100%; |
|
height: 100%; |
|
pointer-events: none; |
|
display: flex; |
|
flex-direction: column; |
|
justify-content: center; |
|
align-items: center; |
|
} |
|
|
|
.time { |
|
font-size: 20px; |
|
font-weight: bold; |
|
color: #06b6d4; |
|
text-align: center; |
|
} |
|
|
|
.dateInfo { |
|
font-size: 12px; |
|
color: #fbbf24; |
|
text-align: center; |
|
line-height: 1.3; |
|
margin-top: 5px; |
|
} |
|
|
|
.moon { |
|
font-size: 26px; |
|
margin-bottom: -5px; |
|
} |
|
|
|
.moonText { |
|
font-size: 12px; |
|
color: #fbbf24; |
|
} |
|
|
|
#sunTimes { |
|
margin-top: 8px; |
|
text-align: center; |
|
font-size: 14px; |
|
color: #fbbf24; |
|
} |
|
|
|
#geoMsg { |
|
margin-top: 4px; |
|
text-align: center; |
|
font-size: 13px; |
|
color: #ef4444; |
|
} |
|
|
|
form { |
|
margin-top: 15px; |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 8px; |
|
justify-content: center; |
|
} |
|
|
|
input[type="time"] { |
|
padding: 6px; |
|
border-radius: 6px; |
|
border: none; |
|
} |
|
|
|
button { |
|
padding: 6px 10px; |
|
border-radius: 6px; |
|
border: none; |
|
cursor: pointer; |
|
} |
|
|
|
.addBtn { |
|
flex-basis: 100%; |
|
max-width: 150px; |
|
margin-top: 5px; |
|
background: #06b6d4; |
|
color: #111; |
|
} |
|
|
|
ul { |
|
list-style: none; |
|
padding: 0; |
|
margin-top: 15px; |
|
} |
|
|
|
li { |
|
display: flex; |
|
justify-content: space-between; |
|
align-items: center; |
|
padding: 6px 8px; |
|
margin-bottom: 6px; |
|
background: #1f2937; |
|
border-radius: 6px; |
|
flex-wrap: wrap; |
|
gap: 6px; |
|
} |
|
|
|
.controls { |
|
display: flex; |
|
gap: 6px; |
|
} |
|
|
|
.btn { |
|
padding: 4px 8px; |
|
border-radius: 4px; |
|
border: none; |
|
cursor: pointer; |
|
font-size: 12px; |
|
} |
|
|
|
.delBtn { |
|
background: #ef4444; |
|
color: white; |
|
} |
|
|
|
.editBtn { |
|
background: #f59e0b; |
|
color: white; |
|
} |
|
|
|
.toggleBtn { |
|
background: #06b6d4; |
|
color: #111; |
|
} |
|
|
|
.daysContainer { |
|
display: flex; |
|
gap: 2px; |
|
margin: 4px 0; |
|
} |
|
|
|
.dayBtn { |
|
width: 20px; |
|
height: 20px; |
|
border-radius: 3px; |
|
border: none; |
|
cursor: pointer; |
|
font-size: 11px; |
|
font-weight: bold; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.dayBtn.on { |
|
background: #10b981; |
|
color: white; |
|
} |
|
|
|
.dayBtn.off { |
|
background: #6b7280; |
|
color: #9ca3af; |
|
} |
|
|
|
.slotInfo { |
|
display: flex; |
|
flex-direction: column; |
|
} |
|
|
|
.sun-based-indicator { |
|
color: #fbbf24; |
|
font-size: 11px; |
|
margin-top: 2px; |
|
} |
|
|
|
/* New styles for sunrise/sunset form */ |
|
.sun-form { |
|
margin-top: 15px; |
|
padding: 12px; |
|
background: #1f2937; |
|
border-radius: 8px; |
|
} |
|
|
|
.sun-form h3 { |
|
margin: 0 0 10px 0; |
|
font-size: 16px; |
|
color: #fbbf24; |
|
text-align: center; |
|
} |
|
|
|
.sun-form-row { |
|
display: flex; |
|
flex-wrap: wrap; |
|
gap: 8px; |
|
margin-bottom: 8px; |
|
} |
|
|
|
.sun-form-row label { |
|
flex: 1; |
|
min-width: 120px; |
|
} |
|
|
|
.sun-form-row select { |
|
flex: 1; |
|
min-width: 120px; |
|
padding: 6px; |
|
border-radius: 6px; |
|
border: none; |
|
background: #374151; |
|
color: #eee; |
|
} |
|
|
|
.sun-form-row .addBtn { |
|
margin-top: 5px; |
|
} |
|
|
|
/* Export section */ |
|
.export-section { |
|
margin-top: 20px; |
|
padding: 12px; |
|
background: #1f2937; |
|
border-radius: 8px; |
|
} |
|
|
|
.export-section h3 { |
|
margin: 0 0 10px 0; |
|
font-size: 16px; |
|
color: #fbbf24; |
|
text-align: center; |
|
} |
|
|
|
.export-buttons { |
|
display: flex; |
|
gap: 10px; |
|
justify-content: center; |
|
} |
|
|
|
.export-btn { |
|
background: #10b981; |
|
color: white; |
|
} |
|
|
|
.import-btn { |
|
background: #f59e0b; |
|
color: white; |
|
} |
|
|
|
/* Footer */ |
|
footer { |
|
margin-top: 30px; |
|
text-align: center; |
|
font-size: 12px; |
|
color: #6b7280; |
|
} |
|
|
|
/* Location button */ |
|
.location-btn { |
|
background: #8b5cf6; |
|
color: white; |
|
margin-top: 10px; |
|
} |
|
</style> |
|
</head> |
|
<body> |
|
<h1>24/7 Circular TimeSlots Timer</h1> |
|
|
|
<div class="day-navigation"> |
|
<button class="nav-btn" id="prevDay">β</button> |
|
<div class="day-slider" id="daySlider"></div> |
|
<button class="nav-btn" id="nextDay">β</button> |
|
</div> |
|
|
|
<div class="current-day-indicator" id="currentDayIndicator"></div> |
|
|
|
<div class="canvas-container-outer"> |
|
<div class="canvas-wrapper" id="canvasWrapper"></div> |
|
<div class="circle-overlay"> |
|
<div class="moon" id="moonIcon">π</div> |
|
<div class="moonText" id="moonText">Volle Maan</div> |
|
<div class="time" id="centerTime">--:--</div> |
|
<div class="dateInfo" id="dateInfo">--</div> |
|
</div> |
|
</div> |
|
|
|
<div id="sunTimes">Zonsopgang: --:-- | Zonsondergang: --:--</div> |
|
<div id="geoMsg"></div> |
|
|
|
<button class="location-btn" id="getLocationBtn">Gebruik mijn locatie voor zonsopgang/ondergang</button> |
|
|
|
<div class="sun-form"> |
|
<h3>Voeg tijdslot toe op basis van zonsopgang/ondergang</h3> |
|
<div class="sun-form-row"> |
|
<label for="sunEvent">Gebeurtenis:</label> |
|
<select id="sunEvent"> |
|
<option value="sunrise">Zonsopgang</option> |
|
<option value="sunset">Zonsondergang</option> |
|
</select> |
|
</div> |
|
<div class="sun-form-row"> |
|
<label for="sunOffset">Offset:</label> |
|
<select id="sunOffset"> |
|
<option value="-120">2 uur voor</option> |
|
<option value="-90">1,5 uur voor</option> |
|
<option value="-60">1 uur voor</option> |
|
<option value="-45">45 minuten voor</option> |
|
<option value="-30">30 minuten voor</option> |
|
<option value="-15">15 minuten voor</option> |
|
<option value="0">Precies</option> |
|
<option value="15">15 minuten na</option> |
|
<option value="30">30 minuten na</option> |
|
<option value="45">45 minuten na</option> |
|
<option value="60">1 uur na</option> |
|
<option value="90">1,5 uur na</option> |
|
<option value="120">2 uur na</option> |
|
</select> |
|
</div> |
|
<div class="sun-form-row"> |
|
<label for="sunDuration">Duur:</label> |
|
<select id="sunDuration"> |
|
<option value="15">15 minuten</option> |
|
<option value="30">30 minuten</option> |
|
<option value="45">45 minuten</option> |
|
<option value="60">1 uur</option> |
|
<option value="90">1,5 uur</option> |
|
<option value="120">2 uur</option> |
|
<option value="180">3 uur</option> |
|
<option value="240">4 uur</option> |
|
</select> |
|
</div> |
|
<div class="daysContainer"> |
|
<button type="button" class="dayBtn on" data-day="0">M</button> |
|
<button type="button" class="dayBtn on" data-day="1">T</button> |
|
<button type="button" class="dayBtn on" data-day="2">W</button> |
|
<button type="button" class="dayBtn on" data-day="3">T</button> |
|
<button type="button" class="dayBtn on" data-day="4">F</button> |
|
<button type="button" class="dayBtn on" data-day="5">S</button> |
|
<button type="button" class="dayBtn on" data-day="6">S</button> |
|
</div> |
|
<button type="button" class="addBtn" id="addSunSlot">Voeg SunSlot toe</button> |
|
</div> |
|
|
|
<form id="addForm"> |
|
<h3>Voeg tijdslot toe op basis van timepicker</h3> |
|
<input type="time" id="startTime" required> |
|
<input type="time" id="endTime" required> |
|
<div class="daysContainer"> |
|
<button type="button" class="dayBtn on" data-day="0">M</button> |
|
<button type="button" class="dayBtn on" data-day="1">T</button> |
|
<button type="button" class="dayBtn on" data-day="2">W</button> |
|
<button type="button" class="dayBtn on" data-day="3">T</button> |
|
<button type="button" class="dayBtn on" data-day="4">F</button> |
|
<button type="button" class="dayBtn on" data-day="5">S</button> |
|
<button type="button" class="dayBtn on" data-day="6">S</button> |
|
</div> |
|
<button type="submit" class="addBtn">Add TimeSlot</button> |
|
</form> |
|
|
|
<ul id="slotList"></ul> |
|
|
|
<div class="export-section"> |
|
<h3>Exporteer/Importeer Schema</h3> |
|
<div class="export-buttons"> |
|
<button class="export-btn" id="exportBtn">Export naar JSON</button> |
|
<button class="import-btn" id="importBtn">Importeer van JSON</button> |
|
</div> |
|
<textarea id="jsonData" placeholder="JSON data komt hier..." style="width:100%; height:100px; margin-top:10px; display:none; background:#374151; color:#eee; border:none; border-radius:6px; padding:8px;"></textarea> |
|
</div> |
|
|
|
<footer> |
|
<p>Copyright 2025, Dirk Luberth Dijkman Bangert 30 1619GJ Andijk The Netherlands</p> |
|
<p>Voor dagelijks weektijdschema voor bewatering of verlichting</p> |
|
|
|
|
|
https://m.facebook.com/luberth.dijkman/ |
|
<br><br> |
|
|
|
https://codepen.io/ldijkman/pens/public |
|
</footer> |
|
|
|
<script> |
|
// Constants for canvas dimensions |
|
const CANVAS_WIDTH = 320; |
|
const CANVAS_HEIGHT = 320; |
|
const RADIUS = CANVAS_WIDTH / 2 - 30; |
|
const CENTER_X = CANVAS_WIDTH / 2; |
|
const CENTER_Y = CANVAS_HEIGHT / 2; |
|
const STORAGE_KEY = "dagcirkel_slots_v13"; // Incremented version for new features |
|
const dayLetters = ["M", "T", "W", "T", "F", "S", "S"]; |
|
const dayNames = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; |
|
const dayNamesNL = ["Maandag", "Dinsdag", "Woensdag", "Donderdag", "Vrijdag", "Zaterdag", "Zondag"]; |
|
|
|
// Application state |
|
let slots = []; |
|
let sunrise = "07:00", sunset = "19:00"; |
|
let currentViewDay = new Date().getDay(); // 0 (Sunday) to 6 (Saturday) |
|
let scrollTimeout; |
|
const canvasWrapper = document.getElementById("canvasWrapper"); |
|
|
|
// Utility functions |
|
const TimeUtils = { |
|
toMin: function(t) { |
|
const [h, m] = t.split(":").map(Number); |
|
return h * 60 + m; |
|
}, |
|
|
|
toHHMM: function(mins) { |
|
const h = String(Math.floor(mins / 60)).padStart(2, "0"); |
|
const m = String(mins % 60).padStart(2, "0"); |
|
return `${h}:${m}`; |
|
}, |
|
|
|
isInSlot: function(mins, slot) { |
|
const s = this.toMin(slot.start), e = this.toMin(slot.end); |
|
return s < e ? (mins >= s && mins < e) : (mins >= s || mins < e); |
|
}, |
|
|
|
toLocalHHMM: function(d) { |
|
return String(d.getHours()).padStart(2, "0") + ":" + String(d.getMinutes()).padStart(2, "0"); |
|
}, |
|
|
|
formatTimeDiff: function(mins) { |
|
const h = Math.floor(mins / 60); |
|
const m = mins % 60; |
|
return (h > 0 ? h + "h " : "") + m + "m"; |
|
}, |
|
|
|
getISOWeek: function(date) { |
|
const tmp = new Date(date.getTime()); |
|
tmp.setHours(0, 0, 0, 0); |
|
tmp.setDate(tmp.getDate() + 3 - (tmp.getDay() + 6) % 7); |
|
const week1 = new Date(tmp.getFullYear(), 0, 4); |
|
return 1 + Math.round(((tmp - week1) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7); |
|
} |
|
}; |
|
|
|
// Data management functions |
|
const DataManager = { |
|
save: function() { |
|
slots.sort((a, b) => TimeUtils.toMin(a.start) - TimeUtils.toMin(b.start)); |
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(slots)); |
|
}, |
|
|
|
load: function() { |
|
const data = localStorage.getItem(STORAGE_KEY); |
|
if (data) { |
|
slots = JSON.parse(data); |
|
// Ensure all slots have days array (for backward compatibility) |
|
slots.forEach(slot => { |
|
if (!slot.days) { |
|
slot.days = [true, true, true, true, true, true, true]; |
|
} |
|
}); |
|
} else { |
|
// Default slots |
|
slots = [ |
|
{id: 1, start: "06:00", end: "08:00", enabled: true, days: [true, true, true, true, true, true, true]}, |
|
{id: 2, start: "12:00", end: "13:30", enabled: true, days: [true, true, true, true, true, true, true]}, |
|
{id: 3, start: "19:00", end: "22:00", enabled: true, days: [true, true, true, true, true, true, true]} |
|
]; |
|
this.save(); |
|
} |
|
}, |
|
|
|
exportToJson: function() { |
|
const jsonData = JSON.stringify(slots, null, 2); |
|
const textarea = document.getElementById("jsonData"); |
|
textarea.value = jsonData; |
|
textarea.style.display = "block"; |
|
|
|
// Select the text for easy copying |
|
textarea.select(); |
|
textarea.setSelectionRange(0, 99999); // For mobile devices |
|
|
|
// Copy to clipboard |
|
document.execCommand("copy"); |
|
alert("Schema gekopieerd naar klembord!"); |
|
}, |
|
|
|
importFromJson: function() { |
|
const textarea = document.getElementById("jsonData"); |
|
const jsonData = textarea.value.trim(); |
|
|
|
if (!jsonData) { |
|
alert("Voer JSON data in om te importeren"); |
|
return; |
|
} |
|
|
|
try { |
|
const importedSlots = JSON.parse(jsonData); |
|
|
|
// Validate the imported data structure |
|
if (Array.isArray(importedSlots) && importedSlots.every(s => s.id && s.start && s.end)) { |
|
slots = importedSlots; |
|
this.save(); |
|
renderCanvases(); |
|
renderList(); |
|
textarea.style.display = "none"; |
|
alert("Schema succesvol geΓ―mporteerd!"); |
|
} else { |
|
alert("Ongeldige JSON structuur"); |
|
} |
|
} catch (e) { |
|
alert("Ongeldige JSON: " + e.message); |
|
} |
|
} |
|
}; |
|
|
|
// Canvas rendering functions |
|
const CanvasRenderer = { |
|
drawMarkers: function(ctx) { |
|
ctx.textAlign = "center"; |
|
ctx.textBaseline = "middle"; |
|
|
|
// Draw all tickmarks first (quarters, halves, hours) |
|
for (let mins = 0; mins < 1440; mins += 15) { |
|
const angle = mins / 1440 * 2 * Math.PI - Math.PI / 2; |
|
let inner, outer, lineWidth; |
|
|
|
if (mins % 60 === 0) { |
|
// Hour marks |
|
inner = RADIUS - 10; |
|
outer = RADIUS + 10; |
|
lineWidth = 2; |
|
} else if (mins % 30 === 0) { |
|
// Half-hour marks |
|
inner = RADIUS - 10; |
|
outer = RADIUS + 4; |
|
lineWidth = 1; |
|
} else { |
|
// Quarter-hour marks (15 and 45 minutes) |
|
inner = RADIUS - 10; |
|
outer = RADIUS + 0; |
|
lineWidth = .5; |
|
} |
|
|
|
const x1 = CENTER_X + Math.cos(angle) * inner, y1 = CENTER_Y + Math.sin(angle) * inner; |
|
const x2 = CENTER_X + Math.cos(angle) * outer, y2 = CENTER_Y + Math.sin(angle) * outer; |
|
ctx.beginPath(); |
|
ctx.moveTo(x1, y1); |
|
ctx.lineTo(x2, y2); |
|
ctx.strokeStyle = "#aaa"; |
|
ctx.lineWidth = lineWidth; |
|
ctx.stroke(); |
|
} |
|
|
|
// Draw hour labels |
|
for (let hour = 0; hour < 24; hour++) { |
|
const mins = hour * 60; |
|
const angle = mins / 1440 * 2 * Math.PI - Math.PI / 2; |
|
ctx.fillStyle = "#ccc"; |
|
ctx.font = "12px sans-serif"; |
|
const labelX = CENTER_X + Math.cos(angle) * (RADIUS + 20), |
|
labelY = CENTER_Y + Math.sin(angle) * (RADIUS + 20); |
|
ctx.fillText(hour.toString(), labelX, labelY); |
|
} |
|
}, |
|
|
|
drawDayNight: function(ctx) { |
|
const srMin = TimeUtils.toMin(sunrise), ssMin = TimeUtils.toMin(sunset); |
|
const srAngle = srMin / 1440 * 2 * Math.PI - Math.PI / 2; |
|
const ssAngle = ssMin / 1440 * 2 * Math.PI - Math.PI / 2; |
|
ctx.beginPath(); |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, 0, 2 * Math.PI); |
|
ctx.strokeStyle = "#1e3a8a"; |
|
ctx.lineWidth = 12; |
|
ctx.stroke(); |
|
ctx.beginPath(); |
|
if (srMin < ssMin) { |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, srAngle, ssAngle); |
|
} else { |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, srAngle, 1.5 * Math.PI); |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS - 30, -Math.PI / 2, ssAngle); |
|
} |
|
ctx.strokeStyle = "#fbbf24"; |
|
ctx.lineWidth = 12; |
|
ctx.stroke(); |
|
}, |
|
|
|
drawCircularText: function(ctx, text, radius, centerAngle) { |
|
ctx.save(); |
|
ctx.translate(CENTER_X, CENTER_Y); |
|
ctx.rotate(centerAngle); |
|
ctx.fillStyle = "#ccc"; |
|
ctx.font = "14px sans-serif"; |
|
ctx.textAlign = "center"; |
|
ctx.textBaseline = "middle"; |
|
const chars = [...text]; |
|
const totalWidth = chars.reduce((w, ch) => w + ctx.measureText(ch).width, 0); |
|
let offset = -(totalWidth / radius) / 2; |
|
chars.forEach(char => { |
|
const w = ctx.measureText(char).width; |
|
const angle = w / radius; |
|
ctx.rotate(offset + angle / 2); |
|
ctx.save(); |
|
ctx.translate(0, -radius); |
|
ctx.fillText(char, 0, 0); |
|
ctx.restore(); |
|
offset = angle / 2; |
|
}); |
|
ctx.restore(); |
|
}, |
|
|
|
drawCircle: function(canvasEl, dayIndexToDraw) { |
|
const ctx = canvasEl.getContext("2d"); |
|
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); |
|
ctx.beginPath(); |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS, 0, 2 * Math.PI); |
|
ctx.strokeStyle = "#e0e0e0"; |
|
ctx.lineWidth = 24; |
|
ctx.stroke(); |
|
this.drawDayNight(ctx); |
|
|
|
const now = new Date(); |
|
const mins = now.getHours() * 60 + now.getMinutes(); |
|
let stateColor = "grey"; |
|
let inAnySlot = false; |
|
|
|
slots.forEach(slot => { |
|
// Only draw slots for the specified dayIndexToDraw |
|
if (!isSlotActiveOnDay(slot, dayIndexToDraw)) return; |
|
|
|
const sMin = TimeUtils.toMin(slot.start), eMin = TimeUtils.toMin(slot.end); |
|
|
|
// If the canvas being drawn is the *actual* current day, highlight active slots |
|
const isActualCurrentDay = (dayIndexToDraw === now.getDay()); |
|
const active = isActualCurrentDay && TimeUtils.isInSlot(mins, slot); |
|
if (active) inAnySlot = true; |
|
|
|
const color = active ? "limegreen" : "red"; |
|
const startAngle = sMin / 1440 * 2 * Math.PI - Math.PI / 2; |
|
const endAngle = eMin / 1440 * 2 * Math.PI - Math.PI / 2; |
|
ctx.beginPath(); |
|
if (sMin < eMin) { |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS, startAngle, endAngle); |
|
} else { |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS, startAngle, 1.5 * Math.PI); |
|
ctx.arc(CENTER_X, CENTER_Y, RADIUS, -Math.PI / 2, endAngle); |
|
} |
|
ctx.strokeStyle = color; |
|
ctx.lineWidth = 24; |
|
ctx.stroke(); |
|
}); |
|
|
|
this.drawMarkers(ctx); |
|
|
|
// Draw current time indicator only if this is the actual current day |
|
if (dayIndexToDraw === now.getDay()) { |
|
if (slots.some(s => isSlotActiveOnDay(s, dayIndexToDraw))) { |
|
stateColor = inAnySlot ? "limegreen" : "red"; |
|
} |
|
|
|
const angle = mins / 1440 * 2 * Math.PI - Math.PI / 2; |
|
const innerR = 40; |
|
ctx.beginPath(); |
|
ctx.moveTo(CENTER_X + Math.cos(angle) * innerR, CENTER_Y + Math.sin(angle) * innerR); |
|
ctx.lineTo(CENTER_X + Math.cos(angle) * RADIUS, CENTER_Y + Math.sin(angle) * RADIUS); |
|
ctx.strokeStyle = stateColor; |
|
ctx.lineWidth = 4; |
|
ctx.stroke(); |
|
} |
|
|
|
this.drawCircularText(ctx, "Evening", RADIUS - 20, -Math.PI / 4); |
|
this.drawCircularText(ctx, "Night", RADIUS - 20, Math.PI / 4); |
|
this.drawCircularText(ctx, "Morning", RADIUS - 20, 3 * Math.PI / 4); |
|
this.drawCircularText(ctx, "Afternoon", RADIUS - 20, -3 * Math.PI / 4); |
|
} |
|
}; |
|
|
|
// Helper functions |
|
function isSlotActiveOnDay(slot, dayIndex) { |
|
if (!slot.enabled) return false; |
|
// Convert Date.getDay() (0=Sun, 1=Mon) to our array index (0=Mon, 6=Sun) |
|
const ourDayIndex = (dayIndex === 0) ? 6 : dayIndex - 1; |
|
return slot.days[ourDayIndex]; |
|
} |
|
|
|
function getNextStateChangeForDay(now, dayIndex) { |
|
const minsNow = now.getHours() * 60 + now.getMinutes(); |
|
let nextChange = null; |
|
|
|
slots.forEach(slot => { |
|
if (!slot.enabled) return; |
|
if (!isSlotActiveOnDay(slot, dayIndex)) return; |
|
|
|
const s = TimeUtils.toMin(slot.start), e = TimeUtils.toMin(slot.end); |
|
const sDelta = (s - minsNow + 1440) % 1440; |
|
const eDelta = (e - minsNow + 1440) % 1440; |
|
|
|
if (nextChange === null || sDelta < nextChange.mins) { |
|
nextChange = {mins: sDelta, type: 'ON', time: slot.start}; |
|
} |
|
if (nextChange === null || eDelta < nextChange.mins) { |
|
nextChange = {mins: eDelta, type: 'OFF', time: slot.end}; |
|
} |
|
}); |
|
|
|
return nextChange ? `In ${TimeUtils.formatTimeDiff(nextChange.mins)} -> ${nextChange.type} at ${nextChange.time}` : "--"; |
|
} |
|
|
|
function updateCenterOverlay(displayDayIndex) { |
|
const now = new Date(); |
|
const currentDayOfWeek = now.getDay(); |
|
const mins = now.getHours() * 60 + now.getMinutes(); |
|
|
|
document.getElementById("centerTime").textContent = TimeUtils.toHHMM(mins); |
|
|
|
const isToday = (displayDayIndex === currentDayOfWeek); |
|
// Convert Date.getDay() (0=Sun, 1=Mon) to our array index (0=Mon, 6=Sun) for dayNamesNL |
|
const dayName = dayNamesNL[(displayDayIndex === 0) ? 6 : displayDayIndex - 1]; |
|
|
|
const options = { day: 'numeric', month: 'long', year: 'numeric' }; |
|
let dateStr; |
|
let weekNum; |
|
|
|
if (isToday) { |
|
dateStr = now.toLocaleDateString('nl-NL', options); |
|
weekNum = TimeUtils.getISOWeek(now); |
|
} else { |
|
// For other days, calculate the date for that day |
|
const targetDate = new Date(now); |
|
const diff = displayDayIndex - currentDayOfWeek; |
|
targetDate.setDate(now.getDate() + diff); |
|
dateStr = targetDate.toLocaleDateString('nl-NL', options); |
|
weekNum = TimeUtils.getISOWeek(targetDate); |
|
} |
|
|
|
const nextChangeText = getNextStateChangeForDay(now, displayDayIndex); |
|
|
|
document.getElementById("dateInfo").innerHTML = |
|
`${dayName}${isToday ? ' (Vandaag)' : ''}<br>${dateStr}<br>Week ${weekNum}<br>Next: ${nextChangeText}`; |
|
document.getElementById("sunTimes").textContent = `SunRise: ${sunrise} | SunSet: ${sunset}`; |
|
} |
|
|
|
// This function creates and draws the canvases for the previous, current, and next day |
|
function renderCanvases() { |
|
canvasWrapper.innerHTML = ''; |
|
canvasWrapper.style.scrollBehavior = 'auto'; |
|
|
|
const daysToRender = []; |
|
// Calculate previous, current, and next day indices (0-6) |
|
const prevDay = (currentViewDay - 1 + 7) % 7; |
|
const nextDay = (currentViewDay + 1) % 7; |
|
|
|
daysToRender.push({ dayIndex: prevDay, isCurrent: false }); |
|
daysToRender.push({ dayIndex: currentViewDay, isCurrent: true }); |
|
daysToRender.push({ dayIndex: nextDay, isCurrent: false }); |
|
|
|
daysToRender.forEach((dayData, index) => { |
|
const dayCanvasItem = document.createElement('div'); |
|
dayCanvasItem.className = 'day-canvas-item'; |
|
dayCanvasItem.dataset.dayIndex = dayData.dayIndex; |
|
|
|
const canvasEl = document.createElement('canvas'); |
|
canvasEl.width = CANVAS_WIDTH; |
|
canvasEl.height = CANVAS_HEIGHT; |
|
|
|
dayCanvasItem.appendChild(canvasEl); |
|
canvasWrapper.appendChild(dayCanvasItem); |
|
CanvasRenderer.drawCircle(canvasEl, dayData.dayIndex); |
|
}); |
|
|
|
// Scroll to the middle canvas immediately after creation |
|
canvasWrapper.scrollLeft = CANVAS_WIDTH; |
|
canvasWrapper.style.scrollBehavior = 'smooth'; |
|
|
|
updateCenterOverlay(currentViewDay); |
|
} |
|
|
|
function renderDayNavigation() { |
|
const daySlider = document.getElementById("daySlider"); |
|
daySlider.innerHTML = ""; |
|
|
|
const today = new Date().getDay(); |
|
|
|
for (let i = 0; i < 7; i++) { |
|
const dayTab = document.createElement("div"); |
|
dayTab.className = `day-tab ${i === currentViewDay ? 'active' : ''}`; |
|
// Convert Date.getDay() (0=Sun, 1=Mon) to our array index (0=Mon, 6=Sun) for display |
|
dayTab.textContent = dayNamesNL[(i === 0) ? 6 : i - 1]; |
|
dayTab.onclick = (function(index) { |
|
return function() { |
|
currentViewDay = index; |
|
renderDayNavigation(); |
|
renderCanvases(); |
|
renderList(); |
|
}; |
|
})(i); |
|
daySlider.appendChild(dayTab); |
|
} |
|
|
|
// Update current day indicator |
|
const isToday = currentViewDay === today; |
|
document.getElementById("currentDayIndicator").textContent = |
|
`Bekijken: ${dayNamesNL[(currentViewDay === 0) ? 6 : currentViewDay - 1]}${isToday ? ' (Vandaag)' : ''}`; |
|
|
|
// Auto-scroll to center the current day tab |
|
const activeTab = daySlider.querySelector('.day-tab.active'); |
|
if (activeTab) { |
|
const scrollLeft = activeTab.offsetLeft - (daySlider.offsetWidth / 2) + (activeTab.offsetWidth / 2); |
|
daySlider.scrollLeft = scrollLeft; |
|
} |
|
} |
|
|
|
function renderList() { |
|
const ul = document.getElementById("slotList"); |
|
ul.innerHTML = ""; |
|
slots.sort((a, b) => TimeUtils.toMin(a.start) - TimeUtils.toMin(b.start)); |
|
|
|
// Convert currentViewDay to our day index (0=Monday, 6=Sunday) |
|
const viewDayArrayIndex = (currentViewDay === 0) ? 6 : currentViewDay - 1; |
|
|
|
slots.forEach(slot => { |
|
const li = document.createElement("li"); |
|
|
|
const slotInfo = document.createElement("div"); |
|
slotInfo.className = "slotInfo"; |
|
|
|
const timeSpan = document.createElement("span"); |
|
timeSpan.textContent = `${slot.start} -> ${slot.end}`; |
|
timeSpan.style.color = slot.enabled ? "#eee" : "#666"; |
|
slotInfo.appendChild(timeSpan); |
|
|
|
// Show sun-based indicator if applicable |
|
if (slot.sunBased) { |
|
const sunIndicator = document.createElement("div"); |
|
sunIndicator.className = "sun-based-indicator"; |
|
const eventName = slot.sunEvent === "sunrise" ? "Zonsopgang" : "Zonsondergang"; |
|
const offsetText = slot.sunOffset > 0 ? `${slot.sunOffset} min na` : |
|
slot.sunOffset < 0 ? `${Math.abs(slot.sunOffset)} min voor` : "Precies"; |
|
sunIndicator.textContent = `${eventName} ${offsetText} (${slot.sunDuration} min)`; |
|
slotInfo.appendChild(sunIndicator); |
|
} |
|
|
|
// Days toggle buttons |
|
const daysContainer = document.createElement("div"); |
|
daysContainer.className = "daysContainer"; |
|
|
|
dayLetters.forEach((letter, index) => { |
|
const dayBtn = document.createElement("button"); |
|
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`; |
|
dayBtn.textContent = letter; |
|
dayBtn.disabled = !slot.enabled; |
|
dayBtn.onclick = () => { |
|
slot.days[index] = !slot.days[index]; |
|
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`; |
|
DataManager.save(); |
|
renderCanvases(); |
|
}; |
|
daysContainer.appendChild(dayBtn); |
|
}); |
|
|
|
slotInfo.appendChild(daysContainer); |
|
li.appendChild(slotInfo); |
|
|
|
const ctr = document.createElement("div"); |
|
ctr.className = "controls"; |
|
|
|
const tgl = document.createElement("button"); |
|
tgl.textContent = "Toggle"; |
|
tgl.className = "btn toggleBtn"; |
|
tgl.onclick = () => { |
|
slot.enabled = !slot.enabled; |
|
DataManager.save(); |
|
renderList(); |
|
renderCanvases(); |
|
}; |
|
|
|
const edit = document.createElement("button"); |
|
edit.textContent = "Edit"; |
|
edit.className = "btn editBtn"; |
|
edit.onclick = () => { |
|
li.innerHTML = ""; |
|
const startInput = document.createElement("input"); |
|
startInput.type = "time"; |
|
startInput.value = slot.start; |
|
const endInput = document.createElement("input"); |
|
endInput.type = "time"; |
|
endInput.value = slot.end; |
|
|
|
// Days toggle buttons in edit mode |
|
const editDaysContainer = document.createElement("div"); |
|
editDaysContainer.className = "daysContainer"; |
|
|
|
dayLetters.forEach((letter, index) => { |
|
const dayBtn = document.createElement("button"); |
|
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`; |
|
dayBtn.textContent = letter; |
|
dayBtn.onclick = () => { |
|
slot.days[index] = !slot.days[index]; |
|
dayBtn.className = `dayBtn ${slot.days[index] ? 'on' : 'off'}`; |
|
}; |
|
editDaysContainer.appendChild(dayBtn); |
|
}); |
|
|
|
const saveBtn = document.createElement("button"); |
|
saveBtn.textContent = "Save"; |
|
saveBtn.className = "btn toggleBtn"; |
|
saveBtn.onclick = () => { |
|
slot.start = startInput.value; |
|
slot.end = endInput.value; |
|
// Clear sun-based properties when manually editing |
|
delete slot.sunBased; |
|
delete slot.sunEvent; |
|
delete slot.sunOffset; |
|
delete slot.sunDuration; |
|
DataManager.save(); |
|
renderList(); |
|
renderCanvases(); |
|
}; |
|
|
|
li.appendChild(startInput); |
|
li.appendChild(endInput); |
|
li.appendChild(editDaysContainer); |
|
li.appendChild(saveBtn); |
|
}; |
|
|
|
const del = document.createElement("button"); |
|
del.textContent = "Delete"; |
|
del.className = "btn delBtn"; |
|
del.onclick = () => { |
|
slots = slots.filter(s => s.id !== slot.id); |
|
DataManager.save(); |
|
renderList(); |
|
renderCanvases(); |
|
}; |
|
|
|
ctr.appendChild(tgl); |
|
ctr.appendChild(edit); |
|
ctr.appendChild(del); |
|
li.appendChild(ctr); |
|
ul.appendChild(li); |
|
}); |
|
} |
|
|
|
// Initialize form day buttons |
|
function initFormDays() { |
|
const formDays = document.querySelectorAll('#addForm .dayBtn, .sun-form .dayBtn'); |
|
formDays.forEach(btn => { |
|
btn.classList.add('on'); |
|
btn.onclick = () => { |
|
btn.classList.toggle('on'); |
|
btn.classList.toggle('off'); |
|
}; |
|
}); |
|
} |
|
|
|
// Function to recalculate sun-based slots |
|
function recalculateSunBasedSlots() { |
|
slots.forEach(slot => { |
|
if (slot.sunBased) { |
|
// Calculate start time based on sunrise/sunset and offset |
|
const baseTime = slot.sunEvent === "sunrise" ? sunrise : sunset; |
|
const baseMins = TimeUtils.toMin(baseTime); |
|
const startMins = (baseMins + slot.sunOffset + 1440) % 1440; |
|
const endMins = (startMins + slot.sunDuration) % 1440; |
|
|
|
slot.start = TimeUtils.toHHMM(startMins); |
|
slot.end = TimeUtils.toHHMM(endMins); |
|
} |
|
}); |
|
DataManager.save(); |
|
renderCanvases(); |
|
renderList(); |
|
} |
|
|
|
// Get user location for sunrise/sunset data |
|
function getUserLocation() { |
|
if (!navigator.geolocation) { |
|
document.getElementById("geoMsg").textContent = "Geolocatie wordt niet ondersteund door deze browser."; |
|
return; |
|
} |
|
|
|
document.getElementById("geoMsg").textContent = "Locatie ophalen..."; |
|
|
|
navigator.geolocation.getCurrentPosition( |
|
position => { |
|
const lat = position.coords.latitude; |
|
const lon = position.coords.longitude; |
|
fetchSunTimes(lat, lon); |
|
}, |
|
error => { |
|
let message = "Kan locatie niet ophalen: "; |
|
switch(error.code) { |
|
case error.PERMISSION_DENIED: |
|
message += "Gebruiker heeft locatie geweigerd."; |
|
break; |
|
case error.POSITION_UNAVAILABLE: |
|
message += "Locatie informatie is niet beschikbaar."; |
|
break; |
|
case error.TIMEOUT: |
|
message += "Locatie aanvraag is verlopen."; |
|
break; |
|
default: |
|
message += "Onbekende fout."; |
|
} |
|
document.getElementById("geoMsg").textContent = message; |
|
} |
|
); |
|
} |
|
|
|
// Add sunrise/sunset based slot |
|
document.getElementById("addSunSlot").addEventListener("click", () => { |
|
const event = document.getElementById("sunEvent").value; |
|
const offset = parseInt(document.getElementById("sunOffset").value); |
|
const duration = parseInt(document.getElementById("sunDuration").value); |
|
|
|
// Get selected days from form |
|
const formDays = document.querySelectorAll('.sun-form .dayBtn'); |
|
const days = Array.from(formDays).map(btn => btn.classList.contains('on')); |
|
|
|
// Calculate start time based on sunrise/sunset and offset |
|
const baseTime = event === "sunrise" ? sunrise : sunset; |
|
const baseMins = TimeUtils.toMin(baseTime); |
|
const startMins = (baseMins + offset + 1440) % 1440; |
|
const endMins = (startMins + duration) % 1440; |
|
|
|
const startTime = TimeUtils.toHHMM(startMins); |
|
const endTime = TimeUtils.toHHMM(endMins); |
|
|
|
slots.push({ |
|
id: Date.now(), |
|
start: startTime, |
|
end: endTime, |
|
enabled: true, |
|
days: days, |
|
sunBased: true, |
|
sunEvent: event, |
|
sunOffset: offset, |
|
sunDuration: duration |
|
}); |
|
|
|
DataManager.save(); |
|
renderList(); |
|
renderCanvases(); |
|
}); |
|
|
|
// Original form submission |
|
document.getElementById("addForm").addEventListener("submit", e => { |
|
e.preventDefault(); |
|
const st = document.getElementById("startTime").value; |
|
const ed = document.getElementById("endTime").value; |
|
if (!st || !ed) return; |
|
|
|
// Get selected days from form |
|
const formDays = document.querySelectorAll('#addForm .dayBtn'); |
|
const days = Array.from(formDays).map(btn => btn.classList.contains('on')); |
|
|
|
slots.push({id: Date.now(), start: st, end: ed, enabled: true, days: days}); |
|
DataManager.save(); |
|
renderList(); |
|
renderCanvases(); |
|
e.target.reset(); |
|
|
|
// Reset form days to all on |
|
formDays.forEach(btn => { |
|
btn.classList.add('on'); |
|
btn.classList.remove('off'); |
|
}); |
|
}); |
|
|
|
function fetchSunTimes(lat, lon) { |
|
fetch(`https://api.sunrise-sunset.org/json?lat=${lat}&lng=${lon}&formatted=0`) |
|
.then(res => res.json()) |
|
.then(data => { |
|
if (data.status === "OK") { |
|
sunrise = TimeUtils.toLocalHHMM(new Date(data.results.sunrise)); |
|
sunset = TimeUtils.toLocalHHMM(new Date(data.results.sunset)); |
|
document.getElementById("geoMsg").textContent = ""; |
|
|
|
// Recalculate all sun-based slots when sun times change |
|
recalculateSunBasedSlots(); |
|
} |
|
}) |
|
.catch(() => { |
|
document.getElementById("geoMsg").textContent = "Kon sunrise/sunset niet ophalen."; |
|
}); |
|
} |
|
|
|
function updateMoonPhase() { |
|
const phases = ["π","π","π","π","π","π","π","π"]; |
|
const names = ["Nieuwe Maan","Wassende Sikkel","Eerste Kwartier","Wassende Maan", |
|
"Volle Maan","Afnemende Maan","Laatste Kwartier","Afnemende Sikkel"]; |
|
const now = new Date(); |
|
const lp = new Date(Date.UTC(2000, 0, 6, 18, 14)); |
|
const diff = now - lp; |
|
const days = diff / 1000 / 60 / 60 / 24; |
|
const lunations = days / 29.53058867; |
|
const index = Math.floor((lunations - Math.floor(lunations)) * 8 + 0.5) % 8; |
|
document.getElementById("moonIcon").textContent = phases[index]; |
|
document.getElementById("moonText").textContent = names[index]; |
|
} |
|
|
|
// Navigation button handlers for endless scrolling |
|
document.getElementById("prevDay").addEventListener("click", () => { |
|
currentViewDay = (currentViewDay - 1 + 7) % 7; |
|
renderDayNavigation(); |
|
renderCanvases(); |
|
renderList(); |
|
}); |
|
|
|
document.getElementById("nextDay").addEventListener("click", () => { |
|
currentViewDay = (currentViewDay + 1) % 7; |
|
renderDayNavigation(); |
|
renderCanvases(); |
|
renderList(); |
|
}); |
|
|
|
// Handle canvas wrapper scrolling/swiping |
|
canvasWrapper.addEventListener('scroll', () => { |
|
clearTimeout(scrollTimeout); |
|
scrollTimeout = setTimeout(() => { |
|
const scrolledToDayIndex = Math.round(canvasWrapper.scrollLeft / CANVAS_WIDTH); |
|
if (scrolledToDayIndex === 0) { |
|
currentViewDay = (currentViewDay - 1 + 7) % 7; |
|
} else if (scrolledToDayIndex === 2) { |
|
currentViewDay = (currentViewDay + 1) % 7; |
|
} |
|
// If currentViewDay changed, re-render to ensure correct neighbors and list are shown |
|
if (scrolledToDayIndex !== 1) { |
|
renderDayNavigation(); |
|
renderCanvases(); |
|
renderList(); |
|
} |
|
// Always update overlay as it might show different day's info |
|
updateCenterOverlay(currentViewDay); |
|
}, 150); |
|
}); |
|
|
|
// Export/Import functionality |
|
document.getElementById("exportBtn").addEventListener("click", () => { |
|
DataManager.exportToJson(); |
|
}); |
|
|
|
document.getElementById("importBtn").addEventListener("click", () => { |
|
const textarea = document.getElementById("jsonData"); |
|
if (textarea.style.display === "none") { |
|
textarea.style.display = "block"; |
|
textarea.value = ""; |
|
textarea.focus(); |
|
} else { |
|
DataManager.importFromJson(); |
|
} |
|
}); |
|
|
|
// Location button handler |
|
document.getElementById("getLocationBtn").addEventListener("click", getUserLocation); |
|
|
|
// Initial setup |
|
DataManager.load(); |
|
initFormDays(); |
|
renderDayNavigation(); |
|
renderCanvases(); |
|
renderList(); |
|
updateMoonPhase(); |
|
|
|
// Update current time pointer and moon phase every 15 seconds |
|
setInterval(() => { |
|
const currentCanvas = canvasWrapper.querySelector(`.day-canvas-item[data-day-index="${currentViewDay}"] canvas`); |
|
if (currentCanvas) { |
|
CanvasRenderer.drawCircle(currentCanvas, currentViewDay); |
|
} |
|
updateCenterOverlay(currentViewDay); |
|
updateMoonPhase(); |
|
}, 15000); |
|
</script> |
|
</body> |
|
</html> |