From acd2046dd03ca39d5cc7b683022d128094afdb1e Mon Sep 17 00:00:00 2001 From: Rhys18751996 <18751996@student.westernsydney.edu.au> Date: Thu, 18 Dec 2025 09:07:16 +1100 Subject: [PATCH 01/39] Refactor main.js into responsibility-based controllers --- index.html | 33 +++-- js/core/map.js | 56 +++++++++ js/core/markers.js | 97 +++++++++++++++ js/core/paths.js | 77 ++++++++++++ js/core/slider.js | 40 ++++++ js/core/state.js | 14 +++ js/core/ui.js | 44 +++++++ js/main.js | 298 ++------------------------------------------- 8 files changed, 360 insertions(+), 299 deletions(-) create mode 100644 js/core/map.js create mode 100644 js/core/markers.js create mode 100644 js/core/paths.js create mode 100644 js/core/slider.js create mode 100644 js/core/state.js create mode 100644 js/core/ui.js diff --git a/index.html b/index.html index 153d4cb..5303906 100644 --- a/index.html +++ b/index.html @@ -5,19 +5,19 @@ - + - + + Lord of The Rings: Rings of Power - Interactive map + @@ -28,15 +28,30 @@ - - - + + + + + + + + + - + + + + + + + + + + + diff --git a/js/core/map.js b/js/core/map.js new file mode 100644 index 0000000..11658ed --- /dev/null +++ b/js/core/map.js @@ -0,0 +1,56 @@ +window.MapController = (function () { + + const map = L.map('map', { + crs: L.CRS.Simple, + attributionControl: false, + zoom: 0, + minZoom: -1, + maxZoom: 4, + zoomControl: false + }); + + L.control.zoom({ position: 'topright' }).addTo(map); + + const c = new L.Control.Coordinates(); + c.addTo(map); + map.on('click', e => c.setCoordinates(e)); + + const bounds = [[0, 0], [1000, 1366]]; + L.imageOverlay('./img/map.webp', bounds).addTo(map); + map.setView([500, 683]); + + // Draw tool + const drawControl = new L.Control.Draw({ + position: 'topright', + draw: { + polyline: { shapeOptions: { color: '#5e81ac', weight: 4 } }, + polygon: false, + circle: false, + rectangle: false, + circlemarker: false, + marker: false + } + }); + + map.addControl(drawControl); + L.Draw.Polyline.prototype._onTouch = L.Util.falseFn; + + // Dev helper + map.on(L.Draw.Event.CREATED, function (e) { + let output = ""; + e.layer.getLatLngs().forEach(p => { + output += `[${p.lat}, ${p.lng}], `; + }); + console.log(output); + }); + + return { map }; + +})(); + + + +// Now everywhere you used `map`, call +/* +MapController.map +*/ \ No newline at end of file diff --git a/js/core/markers.js b/js/core/markers.js new file mode 100644 index 0000000..9e18a40 --- /dev/null +++ b/js/core/markers.js @@ -0,0 +1,97 @@ +window.MarkersController = (function () { + + function clearMarkers() { + AppState.LIST_MARKERS.forEach(marker => { + marker.removeFrom(MapController.map); + }); + AppState.LIST_MARKERS.length = 0; + } + + function addMarkers() { + + DATA_MARKERS.markers.forEach(marker => { + + // season / episode relevance + let isMarkerRelevant = + marker.episodes.find(e => + e.episode >= AppState.CURRENT_RANGE[0] && + e.episode <= AppState.CURRENT_RANGE[1] && + e.season === 1 + ) !== undefined; + + // movie paths + if (marker.episodes.find(e => e.season === 100) && AppState.LIST_PATHS["Frodo and Sam"]) + isMarkerRelevant = true; + + if (marker.episodes.find(e => e.season === 101) && AppState.LIST_PATHS["Bilbo and Thorin"]) + isMarkerRelevant = true; + + if (!isMarkerRelevant) return; + + const type = DATA_MARKERS.types.find(t => t.name === marker.type); + + const confirmed = marker.isConfirmed + ? "" + : `
coordinates not confirmed
`; + + const readMore = marker.readMoreUrl + ? `
+ + Read more about ${marker.title} + +
` + : ""; + + const listEpisodes = marker.episodes.map(e => { + switch (e.season) { + case 100: return "Lord Of The Rings (Movie)"; + case 101: return "The Hobbit (Movie)"; + default: return `S0${e.season}E0${e.episode}`; + } + }).join(", "); + + const leafletMarker = L.marker(marker.coordinates, { + icon: L.icon({ + iconUrl: `img/markers/${type.icon}`, + iconSize: type.iconSize, + iconAnchor: type.iconAnchor, + popupAnchor: type.popupAnchor + }), + title: marker.title + }) + .bindPopup(` +
+
+ + + +
+
+
+
+

${marker.title}

+
${marker.type}
+ ${confirmed} +
+
+ ${marker.decription} +
+ Seen in: ${listEpisodes} +
+ ${readMore} +
+
+ `) + .addTo(MapController.map); + + AppState.LIST_MARKERS.push(leafletMarker); + }); + } + + return { + clearMarkers, + addMarkers + }; + +})(); diff --git a/js/core/paths.js b/js/core/paths.js new file mode 100644 index 0000000..e871aeb --- /dev/null +++ b/js/core/paths.js @@ -0,0 +1,77 @@ +window.PathsController = (function () { + + function togglePath(characterName) { + const paths = AppState.LIST_PATHS; + + // ADD (animate) + if (!paths[characterName]) { + const layer = L.layerGroup( + getPolylinesFromName(characterName), + { snakingPause: AppState.PATH_SPEED_ANIMATION } + ).addTo(MapController.map); + + // Animate only on first add + layer.snakeIn(); + + paths[characterName] = layer; + + // REMOVE + } else { + paths[characterName].removeFrom(MapController.map); + delete paths[characterName]; + } + + MarkersController.clearMarkers(); + MarkersController.addMarkers(); + } + + // INSTANT refresh — NO animation + function refreshTimelinePaths() { + Object.keys(AppState.LIST_PATHS).forEach(characterName => { + + AppState.LIST_PATHS[characterName].removeFrom(MapController.map); + + AppState.LIST_PATHS[characterName] = + L.layerGroup( + getPolylinesFromName(characterName) + ).addTo(MapController.map); + }); + + MarkersController.clearMarkers(); + MarkersController.addMarkers(); + } + + function getPolylinesFromName(characterName) { + + const filteredPaths = DATA_PATHS.paths.filter(p => + p.character === characterName && + ( + // Episodes + (p.season === 1 && + p.episode >= AppState.CURRENT_RANGE[0] && + p.episode <= AppState.CURRENT_RANGE[1]) + || + // Movies + p.season >= 100 + ) + ); + + const color = + DATA_PATHS.characters.find(c => c.name === characterName).color; + + return filteredPaths.map(p => + L.polyline(p.coordinates, { + color, + weight: AppState.PATH_WEIGHT, + dashArray: p.isConfirmed ? '0' : '2 6', + opacity: p.isConfirmed ? 1 : 0.7 + }) + ); + } + + return { + togglePath, + refreshTimelinePaths + }; + +})(); diff --git a/js/core/slider.js b/js/core/slider.js new file mode 100644 index 0000000..96bd78d --- /dev/null +++ b/js/core/slider.js @@ -0,0 +1,40 @@ +window.SliderController = (function () { + + const slider = document.getElementById('slider'); + + const listEpisodes = [ + 'Prologue', + 'Episode 1', + 'Episode 2', + 'Episode 3', + 'Episode 4', + 'Episode 5', + 'Episode 6', + 'Episode 7', + 'Episode 8' + ]; + + noUiSlider.create(slider, { + start: [0, 8], + connect: true, + step: 1, + range: { min: 0, max: 8 }, + pips: { + mode: 'steps', + density: 100, + filter: () => 2, + format: { + to: value => listEpisodes[value], + from: value => Number(value) + } + } + }); + + slider.noUiSlider.on('update', function (values) { + window.timelineChange([Number(values[0]), Number(values[1])]); + }); + + // initialize timeline state AFTER slider exists + window.timelineChange([0, 8]); + +})(); diff --git a/js/core/state.js b/js/core/state.js new file mode 100644 index 0000000..32f635c --- /dev/null +++ b/js/core/state.js @@ -0,0 +1,14 @@ +// Shared global state +window.AppState = { + LIST_PATHS: {}, + LIST_MARKERS: [], + CURRENT_RANGE: [0, 8], + PATH_SPEED_ANIMATION: 400, + PATH_WEIGHT: 4 +}; + + +// Now all modules access via +/* +const { LIST_PATHS, LIST_MARKERS } = AppState; +*/ \ No newline at end of file diff --git a/js/core/ui.js b/js/core/ui.js new file mode 100644 index 0000000..d3594e9 --- /dev/null +++ b/js/core/ui.js @@ -0,0 +1,44 @@ +window.UIController = (function () { + + /* + * Function: Hide or Show the main menu + */ + function hideshow() { + const button = document.getElementById("main__button-input"); + const menu = document.getElementById("main"); + const timeline = document.getElementById("rangeselect"); + + const isHidden = button.classList.contains('main__button-input--hidden'); + + if (!isHidden) { + button.classList.add("main__button-input--hidden"); + menu.classList.add("menu--hidden"); + timeline.classList.add("slider--hidden"); + } else { + button.classList.remove("main__button-input--hidden"); + menu.classList.remove("menu--hidden"); + timeline.classList.remove("slider--hidden"); + } + } + + /* + * A11Y Function: Trigger checkbox when using keyboard on focused label + * + * @param event KeyboardEvent + */ + function interactionLabel(event) { + const checkbox = event.target.control; + + // Enter (13) or Space (32) + if (event.keyCode === 13 || event.keyCode === 32) { + checkbox.checked = !checkbox.checked; + window.setPath(checkbox); + } + } + + return { + hideshow, + interactionLabel + }; + +})(); diff --git a/js/main.js b/js/main.js index 2221d56..ecbe92d 100644 --- a/js/main.js +++ b/js/main.js @@ -1,291 +1,9 @@ -// Global variables -const LIST_PATHS = {}; -const LIST_MARKERS = []; -const CURRENT_CHAR = []; -const CURRENT_RANGE = [null, null]; -const PATH_SPEED_ANIMATION = 400; -const PATH_WEIGHT = 4; - -// Leaflet map setup -let map = L.map('map', { - crs: L.CRS.Simple, - attributionControl: false, - zoom: 0, - minZoom: -1, - maxZoom: 4, - zoomControl: false -}); - -// Set zoom panel to the top right -L.control.zoom({ - position: 'topright' -}).addTo(map); - -// Show coordinates -let c = new L.Control.Coordinates(); -c.addTo(map); -map.on('click', function(e) { - c.setCoordinates(e); -}); - -// Draw options for measure distance feature -var drawPluginOptions = { - position: 'topright', - draw: { - polyline: { - shapeOptions: { - color: '#5e81ac', - weight: 4 - } - }, - polygon: false, - circle: false, - rectangle: false, - circlemarker: false, - marker: false - } +// attach handlers globally so HTML inline handlers still work +window.setPath = (element) => PathsController.togglePath(element.name); +window.hideshow = UIController.hideshow; +window.interactionLabel = UIController.interactionLabel; + +window.timelineChange = (range) => { + AppState.CURRENT_RANGE = range; + PathsController.refreshTimelinePaths(); }; - -var drawControl = new L.Control.Draw(drawPluginOptions); -map.addControl(drawControl); -L.Draw.Polyline.prototype._onTouch = L.Util.falseFn; // Fix for touchscreen - -// Set the custom map -let bounds = [[0,0], [1000,1366]]; -let image = L.imageOverlay('./img/map.webp', bounds).addTo(map); -map.setView([500,683]); - -// Slider configuration -const slider = document.getElementById('slider'); - -const listEpisodes = ['Prologue', 'Episode 1', 'Episode 2', 'Episode 3', 'Episode 4', 'Episode 5', 'Episode 6', 'Episode 7', 'Episode 8']; -noUiSlider.create(slider, { - start: [0, 8], - connect: true, - step: 1, - range: { - 'min': 0, - 'max': 8 - }, - pips: { - mode: 'steps', - density: 100, - filter: (x) => 2, - format: { - to: function (value) { - return listEpisodes[value]; - }, - from: function (value) { - return Number(value); - } - } - } -}); - -slider.noUiSlider.on('update', function (values) { - timelineChange([Number(values[0]), Number(values[1])]); -}); - - -/* - * Alright, this thing is a real mess, need to be refactored asap -*/ -function setMarker() { - DATA_MARKERS.markers.forEach((marker) => { - - // Will break on season 2, todo - let isMarkerRelevant = marker.episodes.find((elem) => elem.episode >= CURRENT_RANGE[0] && elem.episode <= CURRENT_RANGE[1] && elem.season === 1 ) !== undefined ? true : false; - - if (marker.episodes.find((elem) => elem.season === 100) !== undefined && LIST_PATHS["Frodo and Sam"] !== undefined) - isMarkerRelevant = true; - if (marker.episodes.find((elem) => elem.season === 101) !== undefined && LIST_PATHS["Bilbo and Thorin"] !== undefined) - isMarkerRelevant = true; - - if (isMarkerRelevant) - { - let confirmed = (marker.isConfirmed) ? "" : `
coordinates not confirmed
`; - let readMore = (marker.readMoreUrl) ? `
Read more about ${marker.title}
` : ""; - let type = DATA_MARKERS.types.find((type) => type.name === marker.type); - - let listEpisodes = marker.episodes.map((elem) => { - switch (elem.season) { - case 100: - return "Lord Of The Rings (Movie)"; - case 101: - return "The Hobbit (Movie)"; - default: - return `S0${elem.season}E0${elem.episode}`; - } - }).join(", "); - - LIST_MARKERS.push(L.marker(marker.coordinates, - {icon: L.icon( - { - iconUrl: `img/markers/${type.icon}`, - iconSize: type.iconSize, - iconAnchor: type.iconAnchor, - popupAnchor: type.popupAnchor - } - ), - maxWidth: '500', - title: marker.title - }) - .bindPopup(`

${marker.title}

${marker.type}
${confirmed}
${marker.decription}
Seen in: ${listEpisodes}
${readMore}
`) - .addTo(map)); - } - }); -} - -/* - * Function that triggers setPath function through an input - * - * @param element the input checked/unchecked by the user -*/ -function togglePathCheckbox(element) { - setPath(element.name); -} - -/* - * Function that draw or remove a path from the map - * - * @param element Html checkbox -*/ -function setPath(element) { - const characterName = element.name; - - if (LIST_PATHS[characterName] === undefined) { - LIST_PATHS[characterName] = L.layerGroup(getPolylinesFromName(characterName), { snakingPause: PATH_SPEED_ANIMATION }).addTo(map).snakeIn(); // Set animation - } - else { - LIST_PATHS[characterName].removeFrom(map); - delete LIST_PATHS[characterName]; - } - - LIST_MARKERS.forEach(maker => { - maker.removeFrom(map); - }); - LIST_MAKERS = []; - setMarker(); -} - -/* - * Function that refresh all polylines drawn on the map - * -*/ -function refreshTimelinePaths() { - Object.keys(LIST_PATHS).forEach(characterName => { - if (LIST_PATHS[characterName] != undefined) - LIST_PATHS[characterName].removeFrom(map); - - LIST_PATHS[characterName] = L.layerGroup(getPolylinesFromName(characterName)).addTo(map); - }); - - LIST_MARKERS.forEach(maker => { - maker.removeFrom(map); - }); - LIST_MAKERS = []; - setMarker(); -} - -/* - * Function that returns every polyline of a character - * - * @param characterName string Name of the character - * @return array of L.Polyline -*/ -function getPolylinesFromName(characterName) { - let characterPaths, layerArray = []; - - // TODO : This thing will break on season 2 - characterPaths = DATA_PATHS.paths.filter(path => - (path.character === characterName && - path.episode >= CURRENT_RANGE[0] && - path.episode <= CURRENT_RANGE[1]) || ( - path.character === characterName && - path.season >= 100 // >= are movies - )); - characterColor = DATA_PATHS.characters.find(color => color.name === characterName).color; - - characterPaths.forEach(characterPath => { - let polyLine = L.polyline(characterPath.coordinates, - { - color: characterColor, - weight: PATH_WEIGHT, - dashArray: characterPath.isConfirmed ? '0' : '2 6', - opacity: characterPath.isConfirmed ? '1' : '.7' - }); - layerArray.push(polyLine); - }); - - return layerArray; -} - -/* - * A11Y Function: Trigger checkbox when using keyboard on focused label - * - * @param event of the label -*/ -function interactionLabel(event) { - const checkbox = event.target.control; - - if (event.keyCode === 13 || event.keyCode === 32) { - checkbox.checked === true ? checkbox.checked = false : checkbox.checked = true; - setPath(event.target.control); - } -} - -/* - * Function: Hide or Show the main menu - * -*/ -function hideshow() { - const button = document.getElementById("main__button-input"); - const menu = document.getElementById("main"); - const timeline = document.getElementById("rangeselect"); - - const isHidden = button.classList.contains('main__button-input--hidden'); - - if (!isHidden) { - button.classList.add("main__button-input--hidden"); - menu.classList.add("menu--hidden"); - timeline.classList.add("slider--hidden"); - } - else { - button.classList.remove("main__button-input--hidden"); - menu.classList.remove("menu--hidden"); - timeline.classList.remove("slider--hidden"); - } -} - -/* - * Function: Setter for episode range - * - * @param range Array of 2 ranges -*/ -function setCurrentRange(range) { - if (range != null && range.length === 2) { - CURRENT_RANGE[0] = range[0]; - CURRENT_RANGE[1] = range[1]; - } -} - -/* - * Function: Refresh timeline - * - * @param range Array of 2 ranges -*/ -function timelineChange(range) { - setCurrentRange(range); - refreshTimelinePaths(); // Refresh all the path on the map -} - -// Dev, show paths on console when drawing on the map -map.on(L.Draw.Event.CREATED, function (e) { - var layer = e.layer, output = ""; - - layer.getLatLngs().forEach(element => { - output += `[${element.lat}, ${element.lng}], ` - }); - - console.log(output); - }); \ No newline at end of file From 20f9083e74a9b3dce292a21c3369126eb79b8515 Mon Sep 17 00:00:00 2001 From: Rhys18751996 <18751996@student.westernsydney.edu.au> Date: Thu, 18 Dec 2025 09:16:10 +1100 Subject: [PATCH 02/39] renaming core js files to have Controller suffix --- js/core/{state.js => appState.js} | 0 js/core/{map.js => mapController.js} | 0 js/core/{markers.js => markersController.js} | 0 js/core/{paths.js => pathsController.js} | 0 js/core/{slider.js => sliderController.js} | 0 js/core/{ui.js => uiController.js} | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename js/core/{state.js => appState.js} (100%) rename js/core/{map.js => mapController.js} (100%) rename js/core/{markers.js => markersController.js} (100%) rename js/core/{paths.js => pathsController.js} (100%) rename js/core/{slider.js => sliderController.js} (100%) rename js/core/{ui.js => uiController.js} (100%) diff --git a/js/core/state.js b/js/core/appState.js similarity index 100% rename from js/core/state.js rename to js/core/appState.js diff --git a/js/core/map.js b/js/core/mapController.js similarity index 100% rename from js/core/map.js rename to js/core/mapController.js diff --git a/js/core/markers.js b/js/core/markersController.js similarity index 100% rename from js/core/markers.js rename to js/core/markersController.js diff --git a/js/core/paths.js b/js/core/pathsController.js similarity index 100% rename from js/core/paths.js rename to js/core/pathsController.js diff --git a/js/core/slider.js b/js/core/sliderController.js similarity index 100% rename from js/core/slider.js rename to js/core/sliderController.js diff --git a/js/core/ui.js b/js/core/uiController.js similarity index 100% rename from js/core/ui.js rename to js/core/uiController.js From 2545169bdcc1e820c332ac4523dc8d3e1574a53e Mon Sep 17 00:00:00 2001 From: Rhys18751996 <18751996@student.westernsydney.edu.au> Date: Thu, 18 Dec 2025 11:01:23 +1100 Subject: [PATCH 03/39] feat: Add season selector and dynamic episode slider - Added a season dropdown to filter available characters and markers by season - Integrated SeasonController to manage current season state - Updated SliderController to dynamically adjust episode range based on selected season - Refactored MarkersController to allow adding markers per season - Updated main.js to initialize SeasonController and expose global seasonChange handler - Ensures only relevant characters, paths, and markers are displayed per season --- data/seasonsData.js | 23 ++++++++ index.html | 21 +++++-- js/core/markersController.js | 109 +++++++++++------------------------ js/core/seasonController.js | 87 ++++++++++++++++++++++++++++ js/core/sliderController.js | 32 ++++++++-- js/main.js | 12 ++++ 6 files changed, 197 insertions(+), 87 deletions(-) create mode 100644 data/seasonsData.js create mode 100644 js/core/seasonController.js diff --git a/data/seasonsData.js b/data/seasonsData.js new file mode 100644 index 0000000..1ca5e5a --- /dev/null +++ b/data/seasonsData.js @@ -0,0 +1,23 @@ +// const DATA_SEASONS = [ +// { +// id: 1, +// name: "Rings of Power - Season 1", +// episodes: 8, +// characters: ["Arondir","Elendil","Elrond","Galadriel","Halbrand","Nori"], +// markersRelevant: marker => marker.episodes.some(e => e.season === 1) +// }, +// { +// id: 2, +// name: "Rings of Power - Season 2", +// episodes: 8, +// characters: ["Galadriel","Arondir","NewCharacter2"], // example +// markersRelevant: marker => marker.episodes.some(e => e.season === 2) +// }, +// { +// id: 100, +// name: "The Lord of the Rings (Movies)", +// episodes: 1, +// characters: ["Frodo and Sam","Bilbo and Thorin"], +// markersRelevant: marker => marker.episodes.some(e => e.season === 100 || e.season === 101) +// } +// ]; \ No newline at end of file diff --git a/index.html b/index.html index 5303906..618395b 100644 --- a/index.html +++ b/index.html @@ -43,12 +43,12 @@ - - - - - - + + + + + + @@ -64,7 +64,16 @@

ROP Map