/* ======================================================================== The 2022 /r/place Atlas An Atlas of Reddit's 2022 /r/place, with information to each artwork of the canvas provided by the community. Copyright (c) 2017 Roland Rytz Copyright (c) 2022 Place Atlas contributors Licensed under the GNU Affero General Public License Version 3 https://place-atlas.stefanocoding.me/license.txt ======================================================================== */ const finishButton = document.getElementById("finishButton"); const resetButton = document.getElementById("resetButton"); const undoButton = document.getElementById("undoButton"); const redoButton = document.getElementById("redoButton"); const highlightUnchartedLabel = document.getElementById("highlightUnchartedLabel"); let entryId = 0 const objectInfoBox = document.getElementById("objectInfo"); const hintText = document.getElementById("hint"); const periodsStatus = document.getElementById('periodsStatus') const periodGroups = document.getElementById('periodGroups') const periodGroupTemplate = document.getElementById('period-group').content.firstElementChild.cloneNode(true) const periodsAdd = document.getElementById('periodsAdd') const exportButton = document.getElementById("exportButton"); const cancelButton = document.getElementById("cancelButton"); const exportOverlay = document.getElementById("exportOverlay"); const exportCloseButton = document.getElementById("exportCloseButton"); const exportBackButton = document.getElementById("exportBackButton") const nameField = document.getElementById("nameField") const descriptionField = document.getElementById("descriptionField") const websiteField = document.getElementById("websiteField") const subredditField = document.getElementById("subredditField") const discordField = document.getElementById("discordField") const wikiField = document.getElementById("wikiField") const exportArea = document.getElementById("exportString"); let path = []; let center = [1000, 1000]; let pathWithPeriods = [] let periodGroupElements = [] let disableDrawingOverride = false let drawing = true; let undoHistory = []; const periodClipboard = { "index": null, "path": null } ;[...document.querySelectorAll("#drawControlsContainer textarea")].forEach(el => { el.addEventListener("input", function () { this.style.height = "auto"; this.style.height = (this.scrollHeight) + "px" }) }) window.initDraw = initDraw function initDraw() { wrapper.classList.remove('listHidden') window.render = render window.renderBackground = renderBackground window.updateHovering = updateHovering // let startPeriodField = document.getElementById('startPeriodField') // let endPeriodField = document.getElementById('endPeriodField') // let periodVisbilityInfo = document.getElementById('periodVisbilityInfo') let rShiftPressed = false; let lShiftPressed = false; let shiftPressed = false; let highlightUncharted = true; renderBackground(); applyView(); container.style.cursor = "crosshair"; render(path); container.addEventListener("mousedown", function (e) { lastPos = [ e.clientX, e.clientY ]; }); function getCanvasCoords(x, y) { x = x - container.offsetLeft; y = y - container.offsetTop; const pos = [ ~~((x - (container.clientWidth / 2 - innerContainer.clientWidth / 2 + zoomOrigin[0])) / zoom) + 0.5, ~~((y - (container.clientHeight / 2 - innerContainer.clientHeight / 2 + zoomOrigin[1])) / zoom) + 0.5 ]; if (shiftPressed && path.length > 0) { const previous = path[path.length - 1]; if (Math.abs(pos[1] - previous[1]) > Math.abs(pos[0] - previous[0])) { pos[0] = previous[0]; } else { pos[1] = previous[1]; } } return pos; } container.addEventListener("mouseup", function (e) { if (Math.abs(lastPos[0] - e.clientX) + Math.abs(lastPos[1] - e.clientY) <= 4 && drawing) { const coords = getCanvasCoords(e.clientX, e.clientY); path.push(coords); render(path); undoHistory = []; redoButton.disabled = true; undoButton.disabled = false; if (path.length >= 3) { finishButton.disabled = false; } updatePath() } }); window.addEventListener("mousemove", function (e) { if (!dragging && drawing && path.length > 0) { const coords = getCanvasCoords(e.clientX, e.clientY); render(path.concat([coords])); } }); window.addEventListener("keyup", function (e) { if (e.key == "Enter") { finish(); } else if (e.key == "z" && e.ctrlKey) { undo(); } else if (e.key == "y" && e.ctrlKey) { redo(); } else if (e.key == "Escape") { exportOverlay.style.display = "none"; } else if (e.key === "Shift") { if (e.code === "ShiftRight") { rShiftPressed = false; } else if (e.code === "ShiftLeft") { lShiftPressed = false; } shiftPressed = rShiftPressed || lShiftPressed; } }); window.addEventListener("keydown", function (e) { if (e.key === "Shift") { if (e.code === "ShiftRight") { rShiftPressed = true; } else if (e.code === "ShiftLeft") { lShiftPressed = true; } shiftPressed = rShiftPressed || lShiftPressed; } }); finishButton.addEventListener("click", function (e) { finish(); }); undoButton.addEventListener("click", function (e) { undo(); }); redoButton.addEventListener("click", function (e) { redo(); }); resetButton.addEventListener("click", function (e) { reset(); }); cancelButton.addEventListener("click", function (e) { back(); }); nameField.addEventListener("keyup", function (e) { if (e.key == "Enter") { exportJson(); } }); // websiteField.addEventListener("keyup", function(e){ // if(e.key == "Enter"){ // exportJson(); // } // }); // subredditField.addEventListener("keyup", function(e){ // if(e.key == "Enter"){ // exportJson(); // } // }); exportButton.addEventListener("click", function (e) { exportJson(); }); exportCloseButton.addEventListener("click", function (e) { reset(); exportOverlay.style.display = "none"; }); exportBackButton.addEventListener("click", function (e) { finish(); exportOverlay.style.display = "none"; }); document.getElementById("highlightUncharted").addEventListener("click", function (e) { highlightUncharted = this.checked; render(path); }); function exportJson() { const exportObject = { id: entryId, name: nameField.value, description: descriptionField.value, links: {}, center: {}, path: {}, }; const pathWithPeriodsTemp = pathWithPeriods.concat() for (let i = pathWithPeriodsTemp.length - 1; i > 0; i--) { for (let j = 0; j < i; j++) { if (JSON.stringify(pathWithPeriodsTemp[i][1]) === JSON.stringify(pathWithPeriodsTemp[j][1])) { pathWithPeriodsTemp[j][0] = pathWithPeriodsTemp[i][0] + ', ' + pathWithPeriodsTemp[j][0] pathWithPeriodsTemp.splice(i, 1) break } } } pathWithPeriodsTemp.forEach(([key, value]) => { // TODO: Compress periods on something like 0-13, 14. exportObject.path[key] = value exportObject.center[key] = calculateCenter(value) }) const inputWebsite = websiteField.value.split('\n').map(line => line.trim()).filter(line => line) const inputSubreddit = subredditField.value.split('\n').map(line => line.trim().replace(/(?:(?:(?:(?:https?:\/\/)?(?:(?:www|old|new|np)\.)?)?reddit\.com)?\/)?[rR]\/([A-Za-z0-9][A-Za-z0-9_]{2,20})(?:\/[^" ]*)*/, '$1')).filter(line => line) const inputDiscord = discordField.value.split('\n').map(line => line.trim().replace(/(?:https?:\/\/)?(?:www\.)?(?:(?:discord)?\.?gg|discord(?:app?)\.com\/invite)\/([^\s/]+?)(?=\b)/, '$1')).filter(line => line) const inputWiki = wikiField.value.split('\n').map(line => line.trim().replace(/ /g, '_')).filter(line => line) if (inputWebsite.length) exportObject.links.website = inputWebsite if (inputSubreddit.length) exportObject.links.subreddit = inputSubreddit if (inputDiscord.length) exportObject.links.discord = inputDiscord if (inputWiki.length) exportObject.links.wiki = inputWiki let jsonString = JSON.stringify(exportObject, null, "\t"); jsonString = jsonString.split("\n"); jsonString = jsonString.join("\n "); jsonString = " " + jsonString; exportArea.value = jsonString; let directPostUrl = "https://www.reddit.com/r/placeAtlas2/submit?selftext=true&title=New%20Submission&text=" + encodeURIComponent(exportArea.value); if (jsonString.length > 7493) { directPostUrl = "https://www.reddit.com/r/placeAtlas2/submit?selftext=true&title=New%20Submission&text=" + encodeURIComponent(" " + JSON.stringify(exportObject)); } document.getElementById("exportDirectPost").href = directPostUrl; exportOverlay.style.display = "flex"; exportArea.focus(); exportArea.select(); } function undo() { if (path.length > 0 && drawing) { undoHistory.push(path.pop()); redoButton.disabled = false; updatePath() } } function redo() { if (undoHistory.length > 0 && drawing) { path.push(undoHistory.pop()); undoButton.disabled = false; updatePath() } } function finish() { if (objectInfoBox.style.display === "block") return updatePath() drawing = false; disableDrawingOverride = true; objectInfoBox.style.display = "block"; objectDraw.style.display = "none"; hintText.style.display = "none"; [...document.querySelectorAll("#drawControlsContainer textarea")].forEach(el => { if (el.value) el.style.height = (el.scrollHeight) + "px" }) // if (isOnPeriod()) { // periodVisbilityInfo.textContent = "" // } else { // periodVisbilityInfo.textContent = "Not visible during this period!" // } } function reset() { updatePath([]) undoHistory = []; drawing = true; disableDrawingOverride = false; objectInfoBox.style.display = "none"; objectDraw.style.display = "block"; hintText.style.display = "block"; nameField.value = ""; descriptionField.value = ""; websiteField.value = ""; subredditField.value = ""; pathWithPeriods = [] pathWithPeriods.push([defaultPeriod, []]) initPeriodGroups() } function back() { drawing = true; disableDrawingOverride = false; updatePath() objectInfoBox.style.display = "none"; objectDraw.style.display = "block"; hintText.style.display = "block"; } function renderBackground() { backgroundContext.clearRect(0, 0, canvas.width, canvas.height); backgroundContext.fillStyle = "rgba(0, 0, 0, 1)"; //backgroundContext.fillRect(0, 0, canvas.width, canvas.height); for (let i = 0; i < atlas.length; i++) { const path = atlas[i].path; backgroundContext.beginPath(); if (path[0]) { backgroundContext.moveTo(path[0][0], path[0][1]); } for (let p = 1; p < path.length; p++) { backgroundContext.lineTo(path[p][0], path[p][1]); } backgroundContext.closePath(); backgroundContext.fill(); } } function render(path) { if (!Array.isArray(path)) return context.globalCompositeOperation = "source-over"; context.clearRect(0, 0, canvas.width, canvas.height); if (highlightUncharted) { context.drawImage(backgroundCanvas, 0, 0); context.fillStyle = "rgba(0, 0, 0, 0.4)"; } else { context.fillStyle = "rgba(0, 0, 0, 0.6)"; } context.fillRect(0, 0, canvas.width, canvas.height); context.beginPath(); if (path[0]) { context.moveTo(path[0][0], path[0][1]); } for (let i = 1; i < path.length; i++) { context.lineTo(path[i][0], path[i][1]); } context.closePath(); context.strokeStyle = "rgba(255, 255, 255, 1)"; context.stroke(); context.globalCompositeOperation = "destination-out"; context.fillStyle = "rgba(0, 0, 0, 1)"; context.fill(); } function updateHovering(e, tapped) { if (!dragging && (!fixed || tapped)) { const pos = [ (e.clientX - (container.clientWidth / 2 - innerContainer.clientWidth / 2 + zoomOrigin[0] + container.offsetLeft)) / zoom , (e.clientY - (container.clientHeight / 2 - innerContainer.clientHeight / 2 + zoomOrigin[1] + container.offsetTop)) / zoom ]; const coords_p = document.getElementById("coords_p"); coords_p.innerText = Math.ceil(pos[0]) + ", " + Math.ceil(pos[1]); } } const getEntry = id => { const entries = atlasAll.filter(entry => entry.id === id) if (entries.length === 1) return entries[0] return {} } const params = new URLSearchParams(document.location.search) if (params.has('id')) { const entry = getEntry(params.get('id')) nameField.value = entry.name descriptionField.value = entry.description websiteField.value = entry.links.website.join('\n') subredditField.value = entry.links.subreddit.map(sub => '/r/' + sub).join('\n') discordField.value = entry.links.discord.join('\n') wikiField.value = entry.links.wiki.map(page => page.replace(/_/, ' ')).join('\n') redoButton.disabled = true; undoButton.disabled = false; entryId = params.get('id') Object.entries(entry.path).forEach(([period, path]) => { period.split(", ").forEach(period => { pathWithPeriods.push([period, path]) }) }) } else { pathWithPeriods.push([defaultPeriod, []]) } initPeriodGroups() zoom = 4; zoomOrigin = [ innerContainer.clientWidth / 2 - center[0] * zoom,// + container.offsetLeft innerContainer.clientHeight / 2 - center[1] * zoom// + container.offsetTop ]; scaleZoomOrigin = [ 2000 / 2 - center[0],// + container.offsetLeft 2000 / 2 - center[1]// + container.offsetTop ]; applyView(); document.addEventListener('timeupdate', (event) => { renderBackground() updatePeriodGroups() }) periodsAdd.addEventListener('click', () => { pathWithPeriods.push([defaultPeriod, []]) initPeriodGroups() }) } function calculateCenter(path) { const result = polylabel(path) return [Math.floor(result[0]) + 0.5, Math.floor(result[1]) + 0.5] } function initPeriodGroups() { periodGroupElements = [] periodGroups.textContent = '' pathWithPeriods.forEach(([period, path], index) => { const periodGroupEl = periodGroupTemplate.cloneNode(true) periodGroupEl.id = "periodGroup" + index const startPeriodEl = periodGroupEl.querySelector('.period-start') const endPeriodEl = periodGroupEl.querySelector('.period-end') const periodVisibilityEl = periodGroupEl.querySelector('.period-visible') const periodDeleteEl = periodGroupEl.querySelector('.period-delete') const periodDuplicateEl = periodGroupEl.querySelector('.period-duplicate') const periodVariationEl = periodGroupEl.querySelector('.period-variation') const periodCopyEl = periodGroupEl.querySelector('.period-copy') const [start, end, variation] = parsePeriod(period) startPeriodEl.id = "periodStart" + index startPeriodEl.previousSibling.for = startPeriodEl.id endPeriodEl.id = "periodEnd" + index endPeriodEl.previousSibling.for = endPeriodEl.id periodVisibilityEl.id = "periodVisibility" + index periodVariationEl.id = "periodVariation" + index periodCopyEl.id = "periodCopy" + index startPeriodEl.max = variationsConfig[variation].versions.length - 1 endPeriodEl.max = variationsConfig[variation].versions.length - 1 startPeriodEl.value = start endPeriodEl.value = end if (startPeriodEl.max == 0) periodGroupEl.classList.add('no-time-slider') else periodGroupEl.classList.remove('no-time-slider') startPeriodEl.addEventListener('input', event => { timelineSlider.value = parseInt(event.target.value) updateTime(parseInt(event.target.value), variation) }) endPeriodEl.addEventListener('input', event => { timelineSlider.value = parseInt(event.target.value) updateTime(parseInt(event.target.value), variation) }) periodDeleteEl.addEventListener('click', () => { if (pathWithPeriods.length === 1) return pathWithPeriods = pathWithPeriods.filter((_, i) => i !== index) initPeriodGroups() }) periodDuplicateEl.addEventListener('click', () => { pathWithPeriods.push([pathWithPeriods[index][0], [...pathWithPeriods[index][1]]]) initPeriodGroups() }) periodVariationEl.addEventListener('input', event => { const newVariation = event.target.value const newVariationConfig = variationsConfig[newVariation] startPeriodEl.value = newVariationConfig.default startPeriodEl.max = newVariationConfig.versions.length - 1 endPeriodEl.value = newVariationConfig.default endPeriodEl.max = newVariationConfig.versions.length - 1 if (startPeriodEl.max == 0) periodGroupEl.classList.add('no-time-slider') else periodGroupEl.classList.remove('no-time-slider') updateTime(newVariationConfig.default, newVariation) }) periodCopyEl.addEventListener("click", event => { const index = parseInt(event.target.id.split('periodCopy')[1]) if (event.target.textContent === "Copy") { event.target.textContent = "End" periodClipboard.index = index periodClipboard.path = [...pathWithPeriods[index][1]] updatePeriodGroups() } else if (event.target.textContent === "End") { event.target.textContent = "Copy" periodClipboard.index = null periodClipboard.path = null updatePeriodGroups() } else if (event.target.textContent === "Paste") { pathWithPeriods[index][1] = [...periodClipboard.path] if (pathWithPeriods.length > 2) console.log(pathWithPeriods[2]) initPeriodGroups() } }) periodGroups.appendChild(periodGroupEl) for (const variation in variationsConfig) { const optionEl = document.createElement('option') optionEl.value = variation optionEl.textContent = variationsConfig[variation].name periodVariationEl.appendChild(optionEl) } periodVariationEl.value = variation periodGroupElements.push({ periodGroupEl, startPeriodEl, endPeriodEl, periodVisibilityEl, periodVariationEl, periodCopyEl }) }) updatePeriodGroups() } function updatePeriodGroups() { let pathToActive = [] let lastActivePathIndex let currentActivePathIndex const currentActivePathIndexes = [] periodGroupElements.forEach((elements, index) => { const { periodGroupEl, startPeriodEl, endPeriodEl, periodVisibilityEl, periodVariationEl, periodCopyEl } = elements if (periodGroupEl.dataset.active === "true") lastActivePathIndex = index periodGroupEl.dataset.active = "" if (isOnPeriod( parseInt(startPeriodEl.value), parseInt(endPeriodEl.value), periodVariationEl.value, currentPeriod, currentVariation )) { pathToActive = pathWithPeriods[index][1] currentActivePathIndex = index currentActivePathIndexes.push(index) periodGroupEl.dataset.active = "true" } pathWithPeriods[index][0] = formatPeriod( parseInt(startPeriodEl.value), parseInt(endPeriodEl.value), periodVariationEl.value ) if (periodClipboard.index !== null) { if (index !== periodClipboard.index) { periodCopyEl.textContent = "Paste" if (JSON.stringify(pathWithPeriods[index][1]) === JSON.stringify(periodClipboard.path)) { periodCopyEl.disabled = true } else { periodCopyEl.disabled = false } } else { periodCopyEl.textContent = "End" } } else { periodCopyEl.textContent = "Copy" periodCopyEl.disabled = false } }) periodsStatus.textContent = "" if (lastActivePathIndex !== undefined) { if (lastActivePathIndex === currentActivePathIndex) { // just update the path const { startPeriodEl, endPeriodEl, periodVariationEl } = periodGroupElements[currentActivePathIndex] pathWithPeriods[currentActivePathIndex] = [ formatPeriod( parseInt(startPeriodEl.value), parseInt(endPeriodEl.value), periodVariationEl.value, ), path ] updatePath() } else if (currentActivePathIndex === undefined) { pathWithPeriods[lastActivePathIndex][1] = path updatePath([]) } else { // switch the path pathWithPeriods[lastActivePathIndex][1] = path updatePath(pathToActive) } } else { updatePath(pathToActive) } drawing = disableDrawingOverride ? false : currentActivePathIndex !== undefined } function formatPeriod(start, end, variation) { let periodString if (start === end) periodString = start else periodString = start + "-" + end if (variation !== "default") periodString = variationsConfig[variation].code + ":" + periodString return periodString } function updatePath(newPath) { if (newPath) path = newPath if (path.length > 3) center = calculateCenter(path) render(path) undoButton.disabled = path.length == 0; // Maybe make it undo the cancel action in the future undoHistory = [] updateErrors() } function updateErrors() { if (path.length === 0) { periodsStatus.textContent = "No paths available on this period!" } const [conflicts, invalidPaths, allErrors] = getErrors() if (allErrors.length > 0) { periodsStatus.textContent = `Problems detected. Please check the groups indicated by red.` if (conflicts.length > 0) { periodsStatus.textContent += `\nConflicts on ${conflicts.join(', ')}.` currentActivePathIndex = undefined } if (invalidPaths.length > 0) periodsStatus.textContent += `\nInsufficient paths on ${invalidPaths.join(', ')}.` allErrors.forEach(index => { periodGroupElements[index].periodGroupEl.dataset.status = "error" }) finishButton.disabled = true } else { periodsStatus.textContent = `` finishButton.disabled = false periodGroupElements.forEach((elements, index) => { const { periodGroupEl } = elements if (periodGroupEl.dataset.active === "true") periodGroupEl.dataset.status = "active" else periodGroupEl.dataset.status = "" }) } } function getConflicts() { let conflicts = new Set() for (let i = pathWithPeriods.length - 1; i > 0; i--) { const [start1, end1, period1] = parsePeriod(pathWithPeriods[i][0]) for (let j = 0; j < i; j++) { const [start2, end2, period2] = parsePeriod(pathWithPeriods[j][0]) if (period1 !== period2) continue if ( (start2 <= start1 && start1 <= end2) || (start2 <= end1 && end1 <= end2) || (start1 <= start2 && start2 <= end1) || (start1 <= end2 && end2 <= end1) ) { conflicts.add(i) conflicts.add(j) } } } conflicts = [...conflicts] return conflicts } function getErrors() { const conflicts = getConflicts() const invalidPaths = [] pathWithPeriods.forEach(([period, path], i) => { if (path.length < 3) invalidPaths.push(i) }) // console.info('conflicts', conflicts) // console.info('invalid paths', invalidPaths) return [conflicts, invalidPaths, [...new Set([...conflicts, ...invalidPaths])]] } // function compressPeriod(periodsString) { // let periodStrings = periodsString.split(", ") // let validPeriods = new Set() // periodStrings.forEach(periodString => { // let [start, end] = parsePeriod(periodString) // for (var i = start; i <= end; i++) { // validPeriods.add(i) // } // }) // validPeriods = [...validPeriods].sort() // }