(function(window, document, appState, undefined){ //console.log("app.js"); const svgXMLNS = "http://www.w3.org/2000/svg"; const xmlnsXMLNS = 'http://www.w3.org/2000/xmlns/'; const xmlSpaceXMLNS = 'http://www.w3.org/XML/1998/namespace'; const xmlXlinkXMLNS = 'http://www.w3.org/1999/xlink'; const textPostMaxLines = 7; const maxInt = 2147483647; const polaroidTextMaxLines = 2; const polaroidTextVerticalFudge = -25; const polaroidRandomizeButtonFudge = [0,50]; const textPostDefaultMaxWidth = 400; const textPostLineSpacing = 1.7; const textPostFontSizePixels = 26; const textPostWidthEstimationFudgeFactor = 1; const textPostTextLineLimitFudgeFactor = 1.2; const textPostPadding = [30,40]; const textPostRandomTranslate = [0,0]; const textPostRandomBezier = [10,30]; const textLineRandomTranslate = [22,4]; const textLineRandomBezier = [10,1]; const imageUploadTypeIconSize = "100px"; const imagePostPreviewCanvasMaxDimension = 1024; const polaroidBorderTop = 55; const polaroidBorderLeft = 100; const polaroidBorderBottom = 229; const polaroidBorderRight = 100; const polaroidInsetTop = 51; const polaroidInsetLeft = 51; const polaroidInsetBottom = 215; const polaroidInsetRight = 55; const polaroidBorder = [polaroidBorderTop, polaroidBorderLeft, polaroidBorderBottom, polaroidBorderRight]; const polaroidBorderThickness = 0.5; const backgroundImageThumbnailBPGDataURI = "data:image/bpg;base64,QlBH+yAAggCCAAADkkdARAHBcYMSAAABJgGvCEgmMEMZQLWl4Nfa62/sLtgsBdsYs7rqKFN/lk16+VmPanaO50tVLXKpXtXv6IA7IxMS734VZGRKbk61L/22O6fvdDR0+k3y//DdKcEfpVlQP4qb4PoS6ggQPNA/g5VIECpY1Jdtl9g0UOT8lgV3KuR9qUZzJqDC5ZOyPQbnIxcU5o7DF0TrOaMggkH07mxy/vqZm22SnfkGQzx7IQ1XtUYysFJ5Lnn1F6Ba48HNUX5dhjFLcuzkIor/DSCH/+9mn1J6iNFoWTR+nOknAvwATHL0XCy6qWTetwEWgJSqX/OaPFaSxMANQ8vTNZ9jC/VtNcUq64tBv9ZLas6PONnZxkHtiGOkCZrNjz+6Q+ongsjbrnLYDsvVw/bArLjG2LeAnVpXfnbTyXycBcXvTvGkKSe5GRzzUMbOKlFp6z1U2sRk8l0onFq4Y/UcvzPPIvRnpazMW3315d2+YXyE6g/HlzeTPYCu18q/qJ5WtVnrsEYw6sS1BkfweYiUf1AQUD9Ee6zA8uiT2dxTbgqMfq//1PbihCo5wAjGCYWVxIuefG1zNfwKJfIq1hV3sy2utIVxjWT+LWfXrFV3KA/75/MI2km2P8RKc7jZWBInW7IjrrswSMY7JZJq63+CxslP5PBSK1C9HPMnSAvV"; const jpegHeaderBase64 = "/9j/2wCEADUlKC8oITUvKy88OTU/UIVXUElJUKN1e2GFwarLyL6qurfV8P//1eL/5re6////////////zv////////////8BOTw8UEZQnVdXnf/cutz////////////////////////////////////////////////////////////////////AABEIACAAIAMBIgACEQEDEQH/xAGiAAABBQEBAQEBAQAAAAAAAAAAAQIDBAUGBwgJCgsQAAIBAwMCBAMFBQQEAAABfQECAwAEEQUSITFBBhNRYQcicRQygZGhCCNCscEVUtHwJDNicoIJChYXGBkaJSYnKCkqNDU2Nzg5OkNERUZHSElKU1RVVldYWVpjZGVmZ2hpanN0dXZ3eHl6g4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2drh4uPk5ebn6Onq8fLz9PX29/j5+gEAAwEBAQEBAQEBAQAAAAAAAAECAwQFBgcICQoLEQACAQIEBAMEBwUEBAABAncAAQIDEQQFITEGEkFRB2FxEyIygQgUQpGhscEJIzNS8BVictEKFiQ04SXxFxgZGiYnKCkqNTY3ODk6Q0RFRkdISUpTVFVWV1hZWmNkZWZnaGlqc3R1dnd4eXqCg4SFhoeIiYqSk5SVlpeYmZqio6Slpqeoqaqys7S1tre4ubrCw8TFxsfIycrS09TV1tfY2dri4+Tl5ufo6ery8/T19vf4+fr/2gAMAwEAAhEDEQA/A"; // see static/test.png for the source of this image. const testBPGImageDataURI = "data:image/bpg;base64,QlBH+3AAICAAA5JCUAOSQlBECcF1gRIAAAABRAHBdYESAAABJgmusLkxB6WjYAAAASYBr1i28siTPC6H7on+4sC2h4bAl2edvY7Djuh2ZwC8S9WS+Y2qtILgTWZX/g4H4h8zG3PPOQWOs4ZscvkyuvyG3aMqtKbtKdCfCFJbNQ=="; const backgroundImageSize = [1000, 656]; const backgroundScale = 2; const postScale = 0.5; const edgePanThreshold = window.innerWidth > window.innerHeight ? window.innerHeight * 0.22 : window.innerWidth * 0.22; const edgePanPower = 2; const edgePanSpeed = 1500; const absorbVelocityRate = 7; const velocityDamping = 5; const postsTilesPollingDurationSeconds = 60; const postTypes = { text: 1, image: 2, video: 3, } let loader; let newPostElement; let inviteElement; let inviteIcon; let postIcon; let inviteLabel; let postLabel; let openNewPost; let openInvite; let textPostPreviewContainer; let imagePostIsTransparent; let imageFile; let imageFileEXIFOrientation; let imagePreviewCanvas; let newPostRandomSeed = new Date().getTime(); let newPostFontIndex = Math.round(mulberry32(newPostRandomSeed)()*Number.MAX_SAFE_INTEGER); let lastMaxLines = 7; let lastDefaultTextValue = "..."; let dragIcon; let postButton; let viewX = 0; let viewY = 0; let velocityX = 0; let velocityY = 0; let positioningPost; let positioningPostX = 0; let positioningPostY = 0; let edgePanX = 0; let edgePanY = 0; let dragPanning = false; let edgePanning = false; let wallContainer; let postsContainer; let loadedPosts = {}; let loadedTiles = {}; let colliderQuadrants = {}; let collidersAllowedOverlapPx = 7; let currentlyZoomedInPost; let currentlyZooming = false; let firstTilePosition = null; let lastInteractionTimestamp = new Date().getTime(); let currentlyLoadingPosts = false; let firstPostsLoaded = false; let supportsBPG; window.addEventListener('DOMContentLoaded', () => { //console.log("DOMContentLoaded"); detectBPGSupport(); }); window.onBPGSupportDetected = (supported) => { //console.log("window.onBPGSupportDetected() was called"); if(supportsBPG !== undefined || supported == undefined) { return; } supportsBPG = supported; window.localStorage['supportsBPG'] = String(supportsBPG); let registrationPage; let lastTextPostValue; loader = document.getElementById("progress-container"); wallContainer = createElement(document.body, "div", { class: "wall", style: { width: `${Math.round(backgroundImageSize[0]*backgroundScale*2)}px`, height: `${Math.round(backgroundImageSize[1]*backgroundScale*2)}px`, } }); postsContainer = createElement(document.body, "div", { class: "posts pointer-events-none", }); createTiledBackground(wallContainer); // https://png-pixel.com clear 1x1 image dragIcon = createElement( document.body, "img", {src: "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== "} ); setupGenericDrag( wallContainer, [wallContainer], (dx, dy) => { viewX -= dx; viewY -= dy; dragPanning = true; }, () => { dragPanning = false; } ); let lastTimestamp; function animationFrame(timestamp) { if(lastTimestamp) { update( (timestamp-lastTimestamp)*0.001 ) } lastTimestamp = timestamp; window.requestAnimationFrame(animationFrame); } window.requestAnimationFrame(animationFrame); let lastViewX = viewX; let lastViewY = viewY; let collisionDetectionFrameCounter = 0; function update(deltaTime) { if(positioningPost) { const dx = edgePanX*deltaTime*edgePanSpeed; const dy = edgePanY*deltaTime*edgePanSpeed; viewX += dx; viewY += dy; positioningPostX += dx; positioningPostY += dy; positioningPost.style.transform = `translate(${positioningPostX}px, ${positioningPostY}px) scale(${postScale}) `; // only run collisionDetect() every 4 frames. if(postButton && (collisionDetectionFrameCounter % 4) != collisionDetectionFrameCounter) { const postContainer = positioningPost.querySelector(".post-container"); if(collisionDetect(createCollider(positioningPost.firstChild))) { postContainer.classList.remove("legal"); postContainer.classList.add("illegal"); postButton.disabled = true; } else { postContainer.classList.add("legal"); postContainer.classList.remove("illegal"); postButton.removeAttribute('disabled'); } collisionDetectionFrameCounter = 0; } collisionDetectionFrameCounter++; } velocityX = lerp(velocityX, 0, velocityDamping*deltaTime); velocityY = lerp(velocityY, 0, velocityDamping*deltaTime); if(edgePanX == 0 && edgePanY == 0 && !dragPanning) { viewX += velocityX*deltaTime; viewY += velocityY*deltaTime; if(edgePanning) { positioningPostX += velocityX*deltaTime; positioningPostY += velocityY*deltaTime; } } else { if(deltaTime != 0) { const realVelocityX = (viewX-lastViewX)/deltaTime; const realVelocityY = (viewY-lastViewY)/deltaTime; lastViewX = viewX; lastViewY = viewY; velocityX = lerp(velocityX, realVelocityX, absorbVelocityRate*deltaTime); velocityY = lerp(velocityY, realVelocityY, absorbVelocityRate*deltaTime); } } let wallX = -viewX; let wallY = -viewY; while(wallX > 0) { wallX -= backgroundImageSize[0]*backgroundScale; } while(wallY > 0) { wallY -= backgroundImageSize[1]*backgroundScale; } wallX = wallX % Math.round(backgroundImageSize[0]*backgroundScale); wallY = wallY % Math.round(backgroundImageSize[1]*backgroundScale); wallContainer.style.transform = `translate(${wallX}px, ${wallY}px)`; postsContainer.style.transform = `translate(${Math.round(-viewX)}px, ${Math.round(-viewY)}px)`; } if( (!appState.IsAuthor && appState.Invited) ) { //console.log("(!appState.IsAuthor && appState.Invited)"); loader.style.display = 'none'; registrationPage = createElement( document.body, "div", {class: "fullscreen-flying registration vertical align-center justify-center"} ); createElement( registrationPage, "h2", {class: "line-height-150 text-center margin-0 padding-2"}, "You're invited to post on our collage wall! 😀" ); const form = createElement(registrationPage, "div", {class: "horizontal align-center padding-2"}); createElement(form, "label", {for: "registration-display-name", class: "text-center"}, "Screen Name: "); const input = createElement(form, "input", {id: "registration-display-name", type: "text"}); createElement(form, "span", null, " (Optional)"); const button = createElement(registrationPage, "button", null, "Continue"); button.onclick = () => { window.location = `/register?displayName=${input.value.trim()}`; }; return } const overlay = createElement(document.body, "div", {class: "fullscreen-flying pointer-events-none"}); if(appState.TemporaryError) { createElement( overlay, "div", {class: "temporary-error text-center padding-1 line-height-150"}, appState.TemporaryError ); } else if(!appState.IsAuthor) { createElement( overlay, "div", {class: "registration text-center padding-1 line-height-150"}, `Anyone may view this collage wall, but you must be invited to post. Someone who already has access can invite you, or you can visit the Cyberia Computer Club's booth #R69 to request an invite!` ); } else { createElement(overlay, "div", {class: "instance-id"}, window.graffitiAppState.InstanceId); newPostElement = createElement(overlay, "div", {class: "post-button material-style-button centered-flex"}); postIcon = createElement(newPostElement, "img", { src: "static/spray-paint.webp", alt: "create new post" }); postLabel = createElement(createElement(newPostElement, "label", null), "span", null, "Post"); inviteElement = createElement(overlay, "div", {class: "invite-button material-style-button centered-flex"}); inviteIcon = createElement(inviteElement, "img", { src: "static/qr-code.webp", alt: "invite another user" }); inviteLabel = createElement(createElement(inviteElement, "label", null), "span", null, "Invite"); openInvite = () => { inviteElement.onclick = null; inviteElement.classList.add("invite-expand"); inviteIcon.style.display = "none"; inviteLabel.style.display = "none"; const inviteBody = createElement(document.body, "div", {class: "vertical align-center hidden"}); const animateInviteSize = () => { inviteElement.style.width = `380px`; inviteElement.style.height = `${inviteBody.clientHeight}px`; }; xhr("GET", `invite`, null, (statusCode, responseText) => { if (statusCode == 200) { const inviteCode = responseText; const inviteLink = `${window.location.protocol}//${window.location.host}/join/${inviteCode}`; const inviteLinkHex = string2Hex(inviteLink); createElement(inviteBody, "img", { src: `/qr/${inviteLinkHex}`, alt: "qr code", class: "qr-code" }); const inviteCodeContainer = createElement(inviteBody, "div", {class: "horizontal justify-center margin-2"}); const inviteLinkInput = createElement( createElement(inviteCodeContainer, "div", {class: "invite-code link"}), "input", {value: inviteLink, disabled: true} ); const copyIconContainer = createElement(inviteCodeContainer, "div", {class: "invite-code icon"}); createElement(copyIconContainer, "img", {src: "static/copy-icon.png", alt: "copy invite link"}); const toastContainer = createElement(inviteCodeContainer, "div", {class: "position-absolute"}); copyIconContainer.onclick = () => { inviteLinkInput.removeAttribute("disabled"); inviteLinkInput.select(); inviteLinkInput.setSelectionRange(0, inviteLink.length); const result = document.execCommand("copy"); inviteLinkInput.setSelectionRange(0, 0); inviteLinkInput.disabled = true; const toast = createElement( toastContainer, "div", {class: "invite-link-copied-toast"}, result ? "Link copied to clipboard!" : "Copy to clipboard fail :(" ); setTimeout(() => { toast.style.top = "4.5em"; toast.style.opacity = "1"; }, 10); setTimeout(() => { toast.style.transitionTimingFunction = 'ease-in-out'; toast.style.top = "5.5em"; toast.style.opacity = "0"; setTimeout(() => { toastContainer.removeChild(toast); }, 1000); }, 4000); }; createElement(inviteBody, 'button', { class: "basic-button", onclick: (e) => { inviteElement.classList.remove("invite-expand"); inviteElement.style.removeProperty("width"); inviteElement.style.removeProperty("height"); inviteElement.removeChild(inviteBody); setTimeout(() => { inviteElement.onclick = openInvite; inviteIcon.style.display = "block"; inviteLabel.style.display = "block"; }, 500); }, style: { marginTop: "0", marginBottom: "1rem" } }, "Close"); animateInviteSize(); } else { throw new Error("error attempting to load invite code: " + responseText); } }); setTimeout(() => { inviteElement.appendChild(inviteBody); inviteBody.classList.remove("hidden"); }, 500); }; inviteElement.onclick = openInvite; openNewPost = () => { newPostElement.onclick = null; newPostElement.classList.add("post-expand"); postIcon.style.display = "none"; postLabel.style.display = "none"; let continueButton; const postForm = createElement(document.body, "div", {class: "vertical align-center hidden"}); const contentPreviewArea = createElement(postForm, "div", {class: "vertical align-center"}); const animateFormSize = () => { newPostElement.style.width = `380px`; newPostElement.style.height = `${postForm.clientHeight}px`; }; const validateForm = () => { if(continueButton) { continueButton.disabled = Boolean(textInput?.value?.trim?.() == "" && !imageFile); } }; let textInput; const reRenderTextPostPreview = (maxLines, defaultTextValue) => { lastMaxLines = maxLines; lastDefaultTextValue = defaultTextValue; clearElementContents(textPostPreviewContainer); const isPolaroid = imageFile && !imagePostIsTransparent; const maxWidth = isPolaroid ? Number(imagePreviewCanvas.style.width.replace("px", "")) : textPostDefaultMaxWidth; if(maxWidth == NaN) { throw new Error("reRenderTextPostPreview(): imagePreviewCanvas must have a defined width in pixels.") } const textPostSVGContainer = createElement(textPostPreviewContainer, "div", { class: "vertical align-center", style: { width: `${maxWidth}px`, height: `${Math.round(315*(maxLines/textPostMaxLines))}px`, } }); validateForm(); createText(textPostSVGContainer, textInput.value || defaultTextValue || "", maxLines, newPostRandomSeed, newPostFontIndex, maxWidth); if(isPolaroid) { const svg = textPostSVGContainer.querySelector('svg'); if(svg) { const elementHeightPx = Number(imagePreviewCanvas.style.height.replace("px", "")); const svgWidth = svg.clientWidth; const widthFactor = svgWidth/textPostDefaultMaxWidth; textPostPreviewContainer.style.top = `${Math.round(elementHeightPx+polaroidTextVerticalFudge*widthFactor*polaroidBorderThickness)}px`; } } animateFormSize(); }; const createRandomizeButton = (parent, maxLines) => { const polaroid = parent.querySelector(".polaroid"); const container = createElement(parent, "div", {class: "text-post-randomize-button-container"}); const randomizeButton = createElement( container, "div", { class: "material-style-button text-post-randomize-button centered-flex", onclick: () => { newPostFontIndex ++; newPostRandomSeed = new Date().getTime(); reRenderTextPostPreview(lastMaxLines, lastDefaultTextValue); if(polaroid) { const randomBorder = mulberry32(newPostRandomSeed)() > 0.5 ? '1' : '2'; polaroid.style.borderImageSource = `url('static/polaroid-border-${randomBorder}.webp')`; } } } ); createElement(randomizeButton, "img", { src: "static/dice.webp", alt: "randomize", }); return container; } const switchToTextPost = () => { clearElementContents(contentPreviewArea); displayUploadOptions(); imagePreviewCanvas = null; imageFile = null; const postPreview = createElement(contentPreviewArea, "div", {class: "text-post-container"}); textPostPreviewContainer = createElement(postPreview, "div", {class: "vertical align-center"}); reRenderTextPostPreview(textPostMaxLines, "..."); createRandomizeButton(postPreview); validateForm(); }; const onImageSelected = (e) => { clearElementContents(contentPreviewArea); imageFile = e.target.files[0]; imageFileEXIFOrientation = 0; // getJPEGEXIFOrientationFromFile(imageFile, (orientation) => { // if (orientation > 0) { // imageFileEXIFOrientation = orientation // } // }); createImagePreview(contentPreviewArea, e.target.files[0]).then((canvas) => { imagePreviewCanvas = canvas; const elementHeightPx = Number(canvas.style.height.replace("px", "")); if(elementHeightPx == NaN) { throw new Error("onImageSelected(): Invalid argument canvas: element must have a defined size in pixels.") } imagePostIsTransparent = canvasIsTransparent(canvas); if(imagePostIsTransparent) { // Transparent images don't get the polaroid border -- just handle it like a text post // and slam the image in right above the text. canvas.style.display = 'block'; const postPreview = createElement(contentPreviewArea, "div", { class: "text-post-container vertical align-center", style: { paddingTop: '1em' }, }); postPreview.appendChild(canvas); textPostPreviewContainer = createElement(postPreview, "div", {class: "vertical align-center"}); reRenderTextPostPreview(textPostMaxLines, "..."); createRandomizeButton(contentPreviewArea); } else { // opaque images (photos) get the polaroid border with theh text overlayed on the bottom of the border const randomBorder = mulberry32(newPostRandomSeed)() > 0.5 ? '1' : '2'; const polaroid = createPolaroid(contentPreviewArea, canvas, `polaroid-border-${randomBorder}.webp`); textPostPreviewContainer = createElement( createElement(polaroid, "div", {class: "position-absolute "}), "div", { style: { position: "relative" } } ); reRenderTextPostPreview(polaroidTextMaxLines, ""); const randomizeButton = createRandomizeButton(polaroid); randomizeButton.style.left = `${Math.round(polaroidRandomizeButtonFudge[0]*polaroidBorderThickness)}px`; randomizeButton.style.top = `${Math.round(elementHeightPx+(polaroidBorderBottom+polaroidRandomizeButtonFudge[1])*polaroidBorderThickness)}px`; } validateForm(); }).catch(console.error); }; const onRotateImage = () => { }; const displayUploadOptions = () => { const uploadOptions = createElement(contentPreviewArea, "div", {class: "singleselect-bar horizontal margin-2 upload-options"}); const uploadImageLabel = createElement( uploadOptions, "label", { for: "upload-image" } ); createElement( uploadImageLabel, "img", {src: `static/image.svg`, width: imageUploadTypeIconSize, height: imageUploadTypeIconSize} ); createElement( uploadOptions, "input", { accept: "image/jpeg, image/png", id: "upload-image", type: "file", class: "display-none", onchange: onImageSelected, } ); const captureVideoButton = createElement( uploadOptions, "label", // { // "aria-role": "button", // "onclick": () => { // uploadOptions.style.display = "none"; // }, // } ); createElement( captureVideoButton, "img", { src: `static/video.png`, width: imageUploadTypeIconSize, height: imageUploadTypeIconSize, style: {opacity: "0.4"} } ); createElement( createElement( captureVideoButton, "div", {class: "position-absolute"} ), "span", {class: "upload-video-is-not-supported-yet"}, "video is not supported yet" ); }; const onTextInputChanged = debounce(() => reRenderTextPostPreview(lastMaxLines, lastDefaultTextValue), 250); const checkIfTextInputChanged = () => { if(textInput.value != lastTextPostValue) { onTextInputChanged(); lastTextPostValue = textInput.value; } }; textInput = createElement(postForm, 'textarea', { placeholder: "type your message here...", onchange: checkIfTextInputChanged, onkeyup: checkIfTextInputChanged, }); textInput.focus(); switchToTextPost(); const buttonContainer = createElement(postForm, "div", { class: "horizontal space-between", style: {width: "360px"}, }); createElement(buttonContainer, 'button', { class: "basic-button", onclick: (e) => { newPostElement.classList.remove("post-expand"); newPostElement.style.removeProperty("width"); newPostElement.style.removeProperty("height"); newPostElement.removeChild(postForm); setTimeout(() => { newPostElement.onclick = openNewPost; postIcon.style.display = "block"; postLabel.style.display = "block"; }, 500); }, }, "Cancel"); continueButton = createElement(buttonContainer, 'button', { class: "basic-button", onclick: (e) => { newPostElement.classList.remove("post-expand"); newPostElement.classList.add("post-button-enter-placement-mode"); newPostElement.style.removeProperty("width"); newPostElement.style.removeProperty("height"); newPostElement.removeChild(postForm); enterPostPositioningMode(textInput.value); }, }, "Continue..."); validateForm(); animateFormSize(); setTimeout(() => { newPostElement.appendChild(postForm); postForm.classList.remove("hidden"); }, 500); } newPostElement.onclick = openNewPost; } const centerOfViewX = Math.round(viewX+window.innerWidth*0.5); const centerOfViewY = Math.round(viewY+window.innerHeight*0.5); firstTilePosition = [centerOfViewX, centerOfViewY]; reloadPostsTile(firstTilePosition, "0,0"); setInterval(() => { if(currentlyLoadingPosts) { return; } const predictedFutureX = Math.round(viewX+velocityX+window.innerWidth*0.5); const predictedFutureY = Math.round(viewY+velocityY+window.innerHeight*0.5); const tile = getPostsTile(predictedFutureX, predictedFutureY); if(!loadedTiles[tile.tileKey] || (lastInteractionTimestamp - loadedTiles[tile.tileKey])*0.001 > postsTilesPollingDurationSeconds) { reloadPostsTile(tile.position, tile.tileKey); } }, 500); }; function getPostsTile(x,y) { const tileCoordX = Math.round((x-firstTilePosition[0])/window.graffitiAppState.PostsTileSize); const tileCoordY = Math.round((y-firstTilePosition[1])/window.graffitiAppState.PostsTileSize); return { position: [ firstTilePosition[0]+tileCoordX*window.graffitiAppState.PostsTileSize, firstTilePosition[1]+tileCoordY*window.graffitiAppState.PostsTileSize ], tileKey: `${tileCoordX},${tileCoordY}` }; } function reloadPostsTile(position, tileKey) { currentlyLoadingPosts = true; return new Promise(resolve => { xhr("GET", `posts?x=${position[0]}&y=${position[1]}&cacheBust=${new Date().getTime()}`, null, (statusCode, responseText) => { lastInteractionTimestamp = new Date().getTime(); loadedTiles[tileKey] = new Date().getTime(); onPostsLoaded(statusCode, responseText); currentlyLoadingPosts = false; resolve(); }); }); } function onPostsLoaded(statusCode, postsJSON) { //console.log("onPostsLoaded"); if(statusCode == 200) { if(!firstPostsLoaded) { firstPostsLoaded = true; loader.style.display = 'none'; } const posts = JSON.parse(postsJSON); if(posts.length) { let spawningPostsInterval; spawningPostsInterval = setInterval(() => { let post = posts.shift(); while(posts.length && (loadedPosts[post.id] || document.getElementById(post.id))) { post = posts.shift(); } loadedPosts[post.id] = true; createPost(post, false); if(!posts.length) { clearInterval(spawningPostsInterval) } }, 100); } // posts.forEach(post => { // }); } else { throw new Error("error attempting to load posts: " + postsJSON); } } function enterPostPositioningMode(textContent) { postsContainer.draggable = false; // force re-download of nearby posts -- try to avoid submitting posts that overlap a post someone else just now created. const centerOfViewX = Math.round(viewX+window.innerWidth*0.5); const centerOfViewY = Math.round(viewY+window.innerHeight*0.5); const tile = getPostsTile(centerOfViewX, centerOfViewY); loader.style.display = "block"; reloadPostsTile(tile.position, tile.tileKey).then(() => { loader.style.display = "none"; let width = 0; let height = 0; if(imageFile) { width = Number(imagePreviewCanvas.style.width.replace("px", "")); height = Number(imagePreviewCanvas.style.height.replace("px", "")); if(width == NaN || height == NaN) { throw new Error("enterPostPositioningMode(): imagePreviewCanvas must have a defined size in pixels."); } } positioningPostX = viewX+(window.innerWidth*0.5); positioningPostY = viewY+(window.innerHeight*0.5); const metadata = { x: Math.round(positioningPostX), y: Math.round(positioningPostY), width: Math.round(width), height: Math.round(height), angle: Math.round(0), randomSeed: newPostRandomSeed, fontIndex: newPostFontIndex, textContent: textContent, //imageFileEXIFOrientation: imageFileEXIFOrientation, type: imageFile ? postTypes.image : postTypes.text, transparentImage: imagePostIsTransparent, }; positioningPost = createPost(metadata, true); const testCollider = createCollider(positioningPost.firstChild); if(collisionDetect(testCollider)) { const distanceIncrementPixels = 50; const directionsToTry = getRandomPermutation([[1,0],[1,1],[0,1],[-1,1],[-1,0],[-1,-1],[0,-1],[1,-1]]); let foundASpot = false; let i = 1; while(i < 200 && !foundASpot) { for(const direction of directionsToTry) { const dx = direction[0]*i*distanceIncrementPixels; const dy = direction[1]*i*distanceIncrementPixels; if(!collisionDetect(cloneAndTranslateCollider(testCollider, dx, dy))) { foundASpot = true; positioningPostX += dx; positioningPostY += dy; viewX += dx; viewY += dy; break; } } i++; }; } const postContainer = positioningPost.querySelector(".post-container"); postContainer.classList.add("legal"); postContainer.style.opacity = "0.5"; setupGenericDrag( positioningPost, [positioningPost, wallContainer], (dx, dy, x, y) => { positioningPostX += dx; positioningPostY += dy; let edgePanMagnitude = [ 1-clamp01(x/edgePanThreshold), clamp01((x-(window.innerWidth-edgePanThreshold))/edgePanThreshold), 1-clamp01(y/edgePanThreshold), clamp01((y-(window.innerHeight-edgePanThreshold))/edgePanThreshold), ].reduce((agg, x) => x > agg ? x : agg, 0); edgePanMagnitude = Math.pow(edgePanMagnitude, edgePanPower); edgePanX = x - window.innerWidth*0.5; edgePanY = y - window.innerHeight*0.5; const magnitude = Math.sqrt(edgePanX*edgePanX + edgePanY*edgePanY); edgePanX = (edgePanX/magnitude)*edgePanMagnitude; edgePanY = (edgePanY/magnitude)*edgePanMagnitude; edgePanning = true; }, () => { edgePanX = 0; edgePanY = 0; edgePanning = false; } ); let overlay; const cleanup = () => { loader.style.display = 'none'; document.body.removeChild(overlay); newPostElement.classList.remove("post-button-enter-placement-mode"); setTimeout(() => { postIcon.style.display = "block"; postLabel.style.display = "block"; newPostElement.onclick = openNewPost; }, 500); postContainer.classList.remove("legal"); postContainer.classList.remove("illegal"); postContainer.style.opacity = "1"; // // TODO figure out how to make it draggable (to pan) AND clickable (to zoom in). // postContainer.draggable = false; }; const postButtonClicked = () => { loader.style.display = 'block'; clearElementContents(overlay); overlay.className = "fullscreen-flying modal-background centered-flex"; metadata.x = Math.round(positioningPostX); metadata.y = Math.round(positioningPostY); metadata.collider = createCollider(positioningPost.firstChild); registerCollider(metadata.collider); setupDragOnPost(positioningPost); positioningPost.style.transition ="transform"; positioningPost.style.transitionTimingFunction = "cubic-bezier(.24,-0.4,.58,1)"; positioningPost.style.transitionDuration = "0.5s"; positioningPost = null; xhr("POST", "upload-meta", metadata, (status, responseText) => { loader.style.display = 'none'; if(status != 200) { cleanup(); throw new Error(`POST /upload-meta: server returned HTTP ${status}: ${responseText}`) } let uploadPromise = Promise.resolve(); if(metadata.type == postTypes.image) { const postId = responseText; uploadPromise = new Promise((resolve, reject) => { const formData = new FormData(); formData.append('file', imageFile); const progressWindow = createElement( createElement(overlay, "div", {class: "centered-flex"}), "div", {class: "progress-window"} ); createElement(progressWindow, "p", null, "Uploading..."); const progressBar = createElement( createElement(progressWindow, "div", {class: "progress-bar-holder"}), "div", {class: "progress-bar"} ); let progressBarInterval = setInterval(() => { xhr("GET", `progress/${postId}`, null, (status, responseText) => { if(status == 200) { progressBar.style.width = `calc(${responseText}% - 6px)`; } else { progressWindow.querySelector("p").textContent = `HTTP ${status}: ${responseText}`; } }); }, 333); xhr("POST", `upload/${postId}`, formData, (status, responseText) => { cleanup(); clearInterval(progressBarInterval); if(status != 200) { throw new Error(`POST /upload-meta: server returned HTTP ${status}: ${responseText}`) } }); }); } else { cleanup(); } }); }; overlay = createElement(document.body, "div", {class: "fullscreen-flying pointer-events-none vertical space-between align-center"}); createElement( overlay, "div", {class: "registration text-center padding-1 line-height-150"}, 'You may touch and drag to position your post, then click POST! to publish it.' ); postButton = createElement( overlay, 'button', { class: "basic-button submit-post-button padding-2", onclick: postButtonClicked, }, "POST!" ); }); } function createCollider(e) { const bounds = e.getBoundingClientRect(); let toReturn; if(bounds.width > bounds.height) { const r = bounds.height*0.5; toReturn = { r: Math.round(r), x1: Math.round(bounds.x+viewX+r), y1: Math.round(bounds.y+viewY+r), x2: Math.round(bounds.x+viewX+(bounds.width-r)), y2: Math.round(bounds.y+viewY+r), } } else { const r = bounds.width*0.5; toReturn = { r: Math.round(r), x1: Math.round(bounds.x+viewX+r), y1: Math.round(bounds.y+viewY+r), x2: Math.round(bounds.x+viewX+r), y2: Math.round(bounds.y+viewY+(bounds.height-r)), } } generateBoundsOnCollider(toReturn); return toReturn; } function generateBoundsOnCollider(c) { const bounds = { minX: (c.x1 < c.x2 ? c.x1 : c.x2)-c.r, maxX: (c.x1 > c.x2 ? c.x1 : c.x2)+c.r, minY: (c.y1 < c.y2 ? c.y1 : c.y2)-c.r, maxY: (c.y1 > c.y2 ? c.y1 : c.y2)+c.r, }; // TODO remove this quick hack later bounds.minX += collidersAllowedOverlapPx bounds.maxX -= collidersAllowedOverlapPx bounds.minY += collidersAllowedOverlapPx bounds.maxY -= collidersAllowedOverlapPx c.bounds = bounds; } function cloneAndTranslateCollider(collider, x, y) { const c = {...collider}; c.bounds = {...collider.bounds}; c.x1 += Math.round(x); c.y1 += Math.round(y); c.x2 += Math.round(x); c.y2 += Math.round(y); c.bounds.minX += x; c.bounds.minY += y; c.bounds.maxX += x; c.bounds.maxY += y; return c; } function registerCollider(c) { generateBoundsOnCollider(c); // all colliders are capsule colliders (2 points and a radius defines a capsule) // pre-calculate the vector and normalized vector along the axis of the capsule. c.vx = c.x1 - c.x2; c.vy = c.y1 - c.y2; const n = normalize(c.vx, c.vy); c.nx = n[0]; c.ny = n[1]; getQuadrantsIntersectingBounds(c.bounds).forEach(k => { if(!colliderQuadrants[k]) { colliderQuadrants[k] = []; } colliderQuadrants[k].push(c); }); } function getQuadrantsIntersectingBounds(bounds) { const quadrants = {}; quadrants[`${Math.round(bounds.minX/1000)},${Math.round(bounds.minY/1000)}`] = true; quadrants[`${Math.round(bounds.maxX/1000)},${Math.round(bounds.minY/1000)}`] = true; quadrants[`${Math.round(bounds.maxX/1000)},${Math.round(bounds.maxY/1000)}`] = true; quadrants[`${Math.round(bounds.minX/1000)},${Math.round(bounds.maxY/1000)}`] = true; return Object.keys(quadrants); } function collisionDetect(collider) { const quadrants = getQuadrantsIntersectingBounds(collider.bounds); for(const quadrant of quadrants) { const collidersInQuadrant = colliderQuadrants[quadrant] || [] for(const other of collidersInQuadrant) { const rect1 = collider.bounds; const rect2 = other.bounds; // https://developer.mozilla.org/en-US/docs/Games/Techniques/2D_collision_detection#axis-aligned_bounding_box if (rect1.minX < rect2.maxX && rect1.maxX > rect2.minX && rect1.minY < rect2.maxY && rect1.maxY > rect2.minY) { // TODO these are supposed to be capsule colliders but I don't have the time for this rn. // const [ix, iy] = intersectTwoLines(collider, other); // const [ix, iy] = intersectTwoLines(collider, other); // // if the lines don't intersect, the must be parallel // if(ix == null && iy == null) { // transpose // } return true } } } return false; } function createPost(metadata, isPreview) { if(document.getElementById(metadata.id)) { return } if(!isPreview) { registerCollider(metadata.collider); } const isTextPost = (!isPreview && metadata.type == postTypes.text) || (isPreview && !imageFile); const anchorPoint = createElement(postsContainer, "div", { class: "position-absolute", id: metadata.id, style:{ transform: `translate(${metadata.x}px, ${metadata.y}px) scale(${postScale})`, } }); // we can't do this yet if its a preview -- the css transitions will destroy the drag and drop physics. // so we have to do it later when we exit drag and drop placement mode if(!isPreview) { anchorPoint.style.transition ="transform"; anchorPoint.style.transitionTimingFunction = "cubic-bezier(.24,-0.4,.58,1)"; anchorPoint.style.transitionDuration = "0.5s"; } const postContainer = createElement(anchorPoint, "div", { class: `position-absolute vertical align-center post-container ${isTextPost ? '' : 'opacity-0'} ${(isTextPost || metadata.transparentImage) ? 'no-polaroid' : ''}`, }); let text; if( isTextPost ) { text = createText(postContainer, metadata.textContent, textPostMaxLines, metadata.randomSeed, metadata.fontIndex); } else { let thumbnailJPEGBase64 = metadata.thumbnailJPEGBase64; if(thumbnailJPEGBase64 && !thumbnailJPEGBase64.startsWith("/9j")) { thumbnailJPEGBase64 = `${jpegHeaderBase64}${thumbnailJPEGBase64}`; } let imageElement; if(isPreview) { imageElement = imagePreviewCanvas; postContainer.classList.remove("opacity-0"); } else { imageElement = createElement(postContainer, "div", { class: "image-container", style:{width: `${metadata.width}px`, height: `${metadata.height}px`} }); const thumbnail = createElement(imageElement, "img", { class: "image-load-blur", style:{ width: `${metadata.width}px`, height: `${metadata.height}px`, position: 'absolute', }, src: `data:image/jpeg;base64,${thumbnailJPEGBase64}`, onload: () => postContainer.classList.remove("opacity-0"), }); imageElement.dataset.filename = metadata.filename; imageElement.dataset.width = metadata.width; imageElement.dataset.height = metadata.height; loadImageIntoContainer( imageElement, `${metadata.filename}-small`, metadata.width, metadata.height, "image-load-blur", (newImage) => { imageElement.removeChild(thumbnail); thumbnail.remove(); newImage.dataset.size = "small"; setTimeout(() => newImage.classList.add("resolve-0"), 10); setTimeout(() => newImage.classList.add("resolve"), 510); } ); } if(metadata.transparentImage) { postContainer.appendChild(imageElement); if(metadata.textContent) { text = createText(postContainer, metadata.textContent, textPostMaxLines, metadata.randomSeed, metadata.fontIndex, textPostDefaultMaxWidth); } } else { const randomBorder = mulberry32(metadata.RandomSeed)() > 0.5 ? '1' : '2'; const polaroid = createPolaroid(postContainer, imageElement, `polaroid-border-${randomBorder}.webp`); if(metadata.textContent) { const widthFactor = metadata.width/textPostDefaultMaxWidth; const textContainer = createElement( polaroid, "div", { class: "vertical align-center position-absolute", style: { position: "relative", top: `${Math.round(metadata.height+polaroidTextVerticalFudge*widthFactor*polaroidBorderThickness)}px`, width: `${metadata.width}px`, height: `${Math.round(315*(polaroidTextMaxLines/textPostMaxLines))}px` } } ); text = createText(textContainer, metadata.textContent, polaroidTextMaxLines, metadata.randomSeed, metadata.fontIndex, metadata.width); } } } postContainer.style.left = `${postContainer.clientWidth*-0.5}px`; postContainer.style.top = `${postContainer.clientHeight*-0.5}px`; if(!isPreview) { setupDragOnPost(anchorPoint); } return anchorPoint; } function setupDragOnPost(postElement) { setupGenericDrag( postElement, [postElement, postsContainer], (dx, dy) => { viewX -= dx; viewY -= dy; dragPanning = true; }, () => { dragPanning = false; }, () => { zoomInPost(postElement); } ); } function zoomInPost(postElement) { if(currentlyZooming) { return } currentlyZooming = true; // we zoom in the post as long as one is not already zoomed in. if(currentlyZoomedInPost != null) { zoomOutPost(); return } currentlyZoomedInPost = postElement; const bounds = postElement.firstChild.getBoundingClientRect(); postElement.dataset.originalTransform = postElement.style.transform; const match = /translate\(([0-9.-]+)px, ([0-9.-]+)px\)/.exec(postElement.style.transform); const originalX = Number(match[1]); const originalY = Number(match[2]); const newX = originalX + ((window.innerWidth *0.5)-((bounds.left+bounds.right )*0.5)); const newY = originalY + ((window.innerHeight*0.5)-((bounds.top +bounds.bottom)*0.5)); const match2 = /scale\(([0-9.]+)\)/.exec(postElement.style.transform); const currentScale = Number(match2[1]); const newScale1 = window.innerWidth/bounds.width; const newScale2 = window.innerHeight/bounds.height; const newScale = currentScale * (newScale1 < newScale2 ? newScale1 : newScale2) * 0.95; postElement.style.zIndex = 100; postElement.style.transform = `translate(${newX}px, ${newY}px) scale(${newScale})`; const noPolaroid = postElement.querySelector(".no-polaroid"); if(noPolaroid) { noPolaroid.classList.add("focused"); } // posts that the user just created won't have .image-container // they are already high-res so we don't need to download the high res version. const imageContainer = postElement.querySelector(".image-container"); if(imageContainer) { const existingImage = imageContainer.querySelector("canvas,img"); loadImageIntoContainer( imageContainer, `${imageContainer.dataset.filename}-large`, imageContainer.dataset.width, imageContainer.dataset.height, "display-none", (newImage) => { if(newImage) { newImage.dataset.size = "large"; } if(newImage && existingImage) { existingImage.classList.add("display-none"); newImage.classList.remove("display-none"); } currentlyZooming = false; } ); } else { currentlyZooming = false; } } function zoomOutPost() { if(currentlyZooming) { return } currentlyZooming = true; if(currentlyZoomedInPost) { currentlyZoomedInPost.style.transform = currentlyZoomedInPost.dataset.originalTransform; const noPolaroid = currentlyZoomedInPost.querySelector(".no-polaroid"); if(noPolaroid) { noPolaroid.classList.remove("focused"); } const prevZoomedInPost = currentlyZoomedInPost; const imageContainer = currentlyZoomedInPost.querySelector(".image-container"); setTimeout(() => { prevZoomedInPost.style.removeProperty("z-index"); // posts that the user just created won't have .image-container // they were already high-res so we don't need clean up the high res version. if(imageContainer) { const images = Array.from(imageContainer.querySelectorAll("canvas,img")); const imagesBySize = {}; images.forEach(e => imagesBySize[e.dataset.size] = e); if (imagesBySize.small && imagesBySize.large) { imagesBySize.small.classList.remove("display-none"); imageContainer.removeChild(imagesBySize.large); imagesBySize.large.remove(); } } currentlyZooming = false; }, 500); currentlyZoomedInPost = null; } } function loadImageIntoContainer(container, filename, width, height, className, onload) { if(supportsBPG) { const result = window.createBPGElement( container, `user-content/${filename}.bpg`, className, `${width}px`, `${height}px` ); result.promise.then(onload); } else { let mainImg; mainImg = createElement(container, "img", { class: className, style: { width: `${width}px`, height: `${height}px`, position: 'absolute', }, src: `user-content/${filename}.jpeg`, onload: () => onload(mainImg) }); } } function createPolaroid(parent, canvas, border) { const elementWidthPx = Number(canvas.style.width.replace("px", "")); const elementHeightPx = Number(canvas.style.height.replace("px", "")); if(elementWidthPx == NaN || elementHeightPx == NaN) { throw new Error("createPolaroid(): Invalid argument canvas: element must have a defined size in pixels."); } const polaroidContainer = createElement(parent, "div", { class: "polaroid-container", style: { width: `${elementWidthPx}px`, height: `${elementHeightPx}px`, } }); polaroidContainer.style.marginTop =`${Math.round(polaroidInsetTop*polaroidBorderThickness)}px`; polaroidContainer.style.marginBottom = `${Math.round(polaroidInsetBottom*polaroidBorderThickness)}px`; polaroidContainer.appendChild(canvas); canvas.classList.add("position-absolute"); createElement(createElement(polaroidContainer, "div", { class: "position-absolute" }), "div", { class: "polaroid", style: { borderImageSource: `url('static/${border}')`, borderImageSlice: `${polaroidBorder.join(" ")}`, borderTop: `${Math.round(polaroidBorderTop*polaroidBorderThickness)}px solid`, borderLeft: `${Math.round(polaroidBorderLeft*polaroidBorderThickness)}px solid`, borderBottom: `${Math.round(polaroidBorderBottom*polaroidBorderThickness)}px solid`, borderRight: `${Math.round(polaroidBorderRight*polaroidBorderThickness)}px solid`, top: `${Math.round(-polaroidInsetTop*polaroidBorderThickness)}px`, left: `${Math.round(-polaroidInsetLeft*polaroidBorderThickness)}px`, bottom: `${Math.round(-polaroidInsetBottom*polaroidBorderThickness)}px`, right: `${Math.round(-polaroidInsetRight*polaroidBorderThickness)}px`, width: `${Math.round(elementWidthPx+(polaroidInsetLeft+polaroidInsetRight)*polaroidBorderThickness)}px`, height: `${Math.round(elementHeightPx+(polaroidInsetTop+polaroidInsetBottom)*polaroidBorderThickness)}px`, } }); canvas.style.display = 'block'; return polaroidContainer; } function createTiledBackground(parent) { if(!supportsBPG) { createElement(parent, "div", { class: "jpeg-background", style: { width: `${backgroundImageSize[0]*backgroundScale*2}px`, height: `${backgroundImageSize[1]*backgroundScale*2}px`, } }); return } let loadedFullRes = false; const makeTile = (canvas, x,y, fullRes) => { if (fullRes != loadedFullRes) { return } const tile = createElement( parent, "canvas", { width: canvas.width, height: canvas.height, class: "position-absolute image-load-blur pointer-events-none", style: { left: `${x*backgroundImageSize[0]*backgroundScale}px`, top: `${y*backgroundImageSize[1]*backgroundScale}px`, width: `${backgroundImageSize[0]*backgroundScale}px`, height: `${backgroundImageSize[1]*backgroundScale}px` }} ); const originalContext = canvas.getContext("2d"); const newContext = tile.getContext("2d"); newContext.putImageData(originalContext.getImageData(0,0,canvas.width,canvas.height), 0, 0); if(fullRes) { setTimeout(() => tile.classList.add("resolve-0"), 10); setTimeout(() => tile.classList.add("resolve"), 510); } }; // first create the thumbnail background -- this should happen pretty much instantly window.createBPGElement( parent, backgroundImageThumbnailBPGDataURI, "position-absolute image-load-blur pointer-events-none", `${backgroundImageSize[0]*backgroundScale}px`, `${backgroundImageSize[1]*backgroundScale}px` ).promise.then((originalTile) => { makeTile(originalTile, 0, 1, false); makeTile(originalTile, 1, 1, false); makeTile(originalTile, 1, 0, false); }); // then download the actual background image, and when it loads, swap out the thumbnail for the real one. window.createBPGElement( document.body, "static/blue-wall-tile-1000.bpg", "position-absolute image-load-blur pointer-events-none display-none", `${backgroundImageSize[0]*backgroundScale}px`, `${backgroundImageSize[1]*backgroundScale}px` ).promise.then((originalTile) => { clearElementContents(parent); loadedFullRes = true; originalTile.classList.remove("display-none"); parent.appendChild(originalTile); setTimeout(() => originalTile.classList.add("resolve-0"), 10); setTimeout(() => originalTile.classList.add("resolve"), 510); makeTile(originalTile, 0, 1, true); makeTile(originalTile, 1, 1, true); makeTile(originalTile, 1, 0, true); }); } function createImagePreview(parent, imgFile) { return new Promise((resolve, reject) => { const img = createElement(document.body, "img", { class: "display-none", src: window.URL.createObjectURL(imgFile), onload: () => { let newWidth = img.naturalWidth; let newHeight = img.naturalHeight; if (newWidth > newHeight) { if (img.naturalWidth > imagePostPreviewCanvasMaxDimension) { newHeight *= imagePostPreviewCanvasMaxDimension / img.naturalWidth; newWidth = imagePostPreviewCanvasMaxDimension; } } else if (img.naturalHeight > imagePostPreviewCanvasMaxDimension) { newWidth *= imagePostPreviewCanvasMaxDimension / img.naturalHeight; newHeight = imagePostPreviewCanvasMaxDimension; } const canvasWidth = newWidth > 380 ? Math.round(newWidth*(380/newWidth)) : Math.round(newWidth); const canvasHeight = newWidth > 380 ? Math.round(newHeight*(380/newWidth)) : Math.round(newHeight); const canvas = createElement( parent, "canvas", { width: Math.round(newWidth), height: Math.round(newHeight), style: { display: "none", width: `${canvasWidth}px`, height: `${canvasHeight}px`, } } ); const context2d = canvas.getContext("2d"); context2d.drawImage(img, 0, 0, Math.round(newWidth), Math.round(newHeight)); document.body.removeChild(img); resolve(canvas); } }); }); } function canvasIsTransparent (canvas) { var context2d = canvas.getContext("2d"); const numberOfOpaquePixels = [ [0,0], [Math.round(canvas.width*0.5),0], [canvas.width-1,0], [canvas.width-1,Math.round(canvas.height*0.5)], [canvas.width-1,canvas.height-1], [Math.round(canvas.width*0.5),canvas.height-1], [0,canvas.height-1], [0,Math.round(canvas.height*0.5)], ].map(([x,y]) => (context2d.getImageData(x, y, 1, 1).data[3] == 255) ? 1 : 0).reduce((a,v) => a+v); return numberOfOpaquePixels < 7; } function setupGenericDrag(element, backgroundElements, onDragged, onDragEnd, onClick) { element.draggable = true; let lastDragX = 0; let lastDragY = 0; let dragging = false; const dragStart = (e) => { if(currentlyZoomedInPost != null) { zoomOutPost(); } dragging = true; lastDragX = e.touches?.[0]?.clientX || e.clientX; lastDragY = e.touches?.[0]?.clientY || e.clientY; e.dataTransfer.setDragImage(dragIcon, 0, 0); }; const dragOver = (e) => { if(!dragging) { return; } lastInteractionTimestamp = new Date().getTime(); const x = e.touches?.[0]?.clientX || e.clientX; const y = e.touches?.[0]?.clientY || e.clientY; onDragged?.(x-lastDragX, y-lastDragY, x,y); lastDragX = x; lastDragY = y; }; element.addEventListener('dragstart', dragStart); element.addEventListener('touchstart', dragStart); backgroundElements.forEach(element => { element.addEventListener('dragover', dragOver); element.addEventListener("touchmove", dragOver); }); element.addEventListener('touchend', () => { dragging = false; onDragEnd?.(); }); element.addEventListener('dragend', () => { dragging = false; onDragEnd?.(); }); element.addEventListener("click", () => { if(currentlyZoomedInPost != null) { zoomOutPost(); } onClick?.(); }); } function createText(parent, text, maxLines, randomSeed, fontIndex, maxWidth, textSizeFactor) { const isPolaroid = polaroidTextMaxLines == maxLines; maxWidth = maxWidth || textPostDefaultMaxWidth; const widthFactor = maxWidth / textPostDefaultMaxWidth; textSizeFactor = textSizeFactor || 1; // max 32 bit int = 2,147,483,647 const prng = mulberry32(randomSeed); const randNorm = () => (prng()-0.5)*2; const fontFamily = [ "ZombieChecklistAlpha, Exposition", "Exposition", "DaisyRaeThin" ][fontIndex % 3]; const fontWeight = [ () => prng() > 0.5 ? "normal" : "bold", () => prng() > 0.5 ? "normal" : "bold", () => "bold" ][fontIndex % 3](); const hue = prng(); let distanceFromTeal = ((hue - 0.25) * 2); distanceFromTeal = distanceFromTeal < 0 ? -distanceFromTeal : distanceFromTeal; distanceFromTeal = distanceFromTeal > 1 ? 1 : distanceFromTeal; const color = hsvToRGBString(prng(), prng()*0.5+0.3, prng()*0.2+0.2 * (1-distanceFromTeal)); let textStyle = {fontFamily, fontWeight, fontSize: `${Math.round(textPostFontSizePixels*textSizeFactor)}px`}; let lineHeight = predictTextSize("TestjGg", textStyle)[1] * textPostLineSpacing; const translateStart = textPostRandomTranslate.map(v => randNorm()*v); const translateEnd = textPostRandomTranslate.map(v => randNorm()*v); const bezierStart = textPostRandomBezier.map(v => randNorm()*v*widthFactor); const bezierEnd = textPostRandomBezier.map(v => randNorm()*v*widthFactor); const lineCurves = [...Array(maxLines)].map((_, i) => { const lineY = (textPostPadding[1]*widthFactor*(isPolaroid?0:1))+((i+1)*lineHeight); const lineTranslateStart = textLineRandomTranslate.map(v => randNorm()*v); const lineTranslateEnd = textLineRandomTranslate.map(v => randNorm()*v); const lineBezierStart = textLineRandomBezier.map(v => randNorm()*v*widthFactor); const lineBezierEnd = textLineRandomBezier.map(v => randNorm()*v*widthFactor); const start = [ textPostPadding[0]+translateStart[0]+lineTranslateStart[0], lineY+((translateStart[1]+lineTranslateStart[1])*widthFactor) ]; const end = [ (maxWidth-textPostPadding[0])+translateEnd[0]+lineTranslateEnd[0], lineY+((translateEnd[1]+lineTranslateEnd[1])*widthFactor) ]; const midpoint = [(start[0]+end[0])*0.5, (start[1]+end[1])*0.5]; return { startPoint: `${start[0]} ${start[1]}`, endPoint: `${end[0]*textPostTextLineLimitFudgeFactor} ${end[1]}`, startHandle: `${midpoint[0]+bezierStart[0]+lineBezierStart[0]} ${midpoint[1]+(bezierStart[1]+lineBezierStart[1])}`, endHandle: `${midpoint[0]+bezierEnd[0]+lineBezierEnd[0]} ${midpoint[1]+(bezierEnd[1]+lineBezierEnd[1])}`, estimatedWidth: (end[0]-start[0]), } }); let widestLineWidth = 0; let lines = text.split("\n").flatMap(line => { const words = line.split(" "); const linesToReturn = []; let lineBuffer = []; words.forEach(word => { const textSize = predictTextSize([...lineBuffer, word].join(" "), textStyle); const curveWidth = lineCurves[linesToReturn.length % lineCurves.length]; const addingCausesWrap = textSize[0]*textPostWidthEstimationFudgeFactor > curveWidth.estimatedWidth; if(!addingCausesWrap && textSize[0] > widestLineWidth) { widestLineWidth = textSize[0]; } if(addingCausesWrap) { widestLineWidth = maxWidth; } if(lineBuffer.length == 0 && addingCausesWrap) { widestLineWidth = curveWidth.estimatedWidth; linesToReturn.push([word]); } else if(addingCausesWrap) { linesToReturn.push(lineBuffer.join(" ")+"\n"); lineBuffer = [word]; } else { lineBuffer.push(word); } }); if(lineBuffer.length && linesToReturn.length < maxLines) { linesToReturn.push(lineBuffer.join(" ")); } return linesToReturn; }); let textPostWidth = (widestLineWidth*textPostWidthEstimationFudgeFactor)+textPostPadding[0]*2; textPostWidth = textPostWidth > maxWidth ? maxWidth : textPostWidth; const widthUtilizationFactor = textPostWidth/maxWidth; const textPostHeight = ((lines.length > maxLines ? maxLines : lines.length) * lineHeight)+textPostPadding[1]*widthUtilizationFactor+textPostPadding[1]; const textPostVerticalPaddingClip = textPostPadding[1] - textPostPadding[1]*widthUtilizationFactor; // if this text could be more than 30% wider without wrapping, start over with bigger font size. if( textSizeFactor == 1 && widthUtilizationFactor < 0.7) { textSizeFactor = 0.95/widthUtilizationFactor; textSizeFactor = textSizeFactor > 2 ? 2 : textSizeFactor; return createText(parent, text, maxLines, randomSeed, fontIndex, maxWidth, textSizeFactor) } // trim lines array to maxLines if(lines.length > maxLines) { lines = lines.slice(0, maxLines) } //lines[lines.length-1] += " "; const textPreview = createElementNS(parent, svgXMLNS, "svg", { "xmlns": [xmlnsXMLNS, svgXMLNS], "xml:space": [xmlSpaceXMLNS, 'preserve'], "version": "1.1", "viewBox": `0 ${textPostVerticalPaddingClip} ${textPostWidth} ${textPostHeight}`, "width": `${textPostWidth}`, "height": `${textPostHeight}`, }); lines.forEach((line, i) => { const defsElement = createElementNS(textPreview, svgXMLNS, "defs"); const textElement = createElementNS(textPreview, svgXMLNS, "text"); createElementNS( defsElement, svgXMLNS, "path", { id: `text-post-preview-path-${i}`, d: `M ${lineCurves[i].startPoint} C ${lineCurves[i].startHandle}, ${lineCurves[i].endHandle}, ${lineCurves[i].endPoint}`, } ) createElementNS( textElement, svgXMLNS, "textPath", { "xlink:href": [xmlXlinkXMLNS, `#text-post-preview-path-${i}`], "style": {fontFamily, fontWeight, fontSize: `${Math.round(textPostFontSizePixels*textSizeFactor)}px`}, "fill": color }, line, ) }); } function detectBPGSupport() { if (window.localStorage['supportsBPG'] !== undefined) { window.onBPGSupportDetected(Boolean(window.localStorage['supportsBPG'])); return } try { setTimeout(() => window.onBPGSupportDetected(false), 1500); const result = window.createBPGElement(document.body, testBPGImageDataURI, "display-none", 32, 32); result.promise.then( () => { const context = result.canvas.getContext("2d"); const upperLeft = context.getImageData(0,0,1,1).data; const upperRight = context.getImageData(31,0,1,1).data; const lowerRight = context.getImageData(31,31,1,1).data; const lowerLeft = context.getImageData(0,31,1,1).data; const upperLeftIsRed = upperLeft[0] > 200 && upperLeft[1] < 50 && upperLeft[2] < 50; const upperRightIsGreen = upperRight[0] < 50 && upperRight[1] > 200 && upperRight[2] < 50; const lowerRightIsWhite = lowerRight[0] > 200 && lowerRight[1] > 200 && lowerRight[2] > 200; const lowerLeftIsBlue = lowerLeft[0] < 50 && lowerLeft[1] < 50 && lowerLeft[2] > 200; window.onBPGSupportDetected(upperLeftIsRed && upperRightIsGreen && lowerRightIsWhite && lowerLeftIsBlue); }, () => { window.onBPGSupportDetected(false); } ) } catch (err) { window.onBPGSupportDetected(false); } } function createElement(parent, tag, attr, textContent) { const element = document.createElement(tag); if(attr) { Object.entries(attr).forEach(([k,v]) => { if (v !== undefined) { if(typeof v == 'function') { element[k] = v; } else if(typeof v == 'object') { Object.entries(v).forEach(([k1,v1]) => { element[k][k1] = v1; }); } else { element.setAttribute(k, v); } } }); } if(textContent) { element.textContent = textContent; } parent.appendChild(element); return element; } function createElementNS(parent, ns, tag, attr, textContent) { const element = document.createElementNS(ns, tag); if(attr) { Object.entries(attr).forEach(([k,v]) => { if(v !== undefined) { if((typeof v) == "string") { element.setAttributeNS(null, k, v) } else if(typeof v == 'object' && !(v instanceof Array)) { Object.entries(v).forEach(([k1,v1]) => { element[k][k1] = v1; }); } else { const [ns, value] = v; element.setAttributeNS(ns, k, value) } } }); } if(textContent) { element.textContent = textContent; } parent.appendChild(element); return element; } function appendFragment(parent, textContent) { const fragment = document.createDocumentFragment() fragment.textContent = textContent parent.appendChild(fragment) } function predictTextSize(text, style) { const measurementDiv = createElement(document.body, "div", {class: "predict-text-width"}, text) Object.entries(style).forEach(([k,v]) => measurementDiv.style[k] = v); const toReturn = [measurementDiv.clientWidth + 1, measurementDiv.clientHeight + 1]; document.body.removeChild(measurementDiv); return toReturn; } function debounce(func, wait, immediate) { var timeout; return () => { var context = this, args = arguments; var later = () => { timeout = null; if (!immediate) func.apply(context, args); }; var callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; }; function xhr(method, url, body, callback) { var request = new XMLHttpRequest(); request.addEventListener("load", function() { callback(this.status, this.responseText); }); request.open(method, url); if(body && typeof body === 'object' && !(body instanceof FormData)) { request.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); body = JSON.stringify(body); } request.send(body); } // https://stackoverflow.com/questions/521295/seeding-the-random-number-generator-in-javascript function mulberry32(a) { return function() { var t = a += 0x6D2B79F5; t = Math.imul(t ^ t >>> 15, t | 1); t ^= t + Math.imul(t ^ t >>> 7, t | 61); return ((t ^ t >>> 14) >>> 0) / 4294967296; } } //remove function clearElementContents(element) { while(element.lastChild) { element.removeChild(element.lastChild); } } function string2Hex(input) { var str = ''; for(var i = 0; i < input.length; i++) { str += input[i].charCodeAt(0).toString(16); } return str; } function hsvToRGBString(h, s, v) { var r, g, b, i, f, p, q, t; if (arguments.length === 1) { s = h.s, v = h.v, h = h.h; } i = Math.floor(h * 6); f = h * 6 - i; p = v * (1 - s); q = v * (1 - f * s); t = v * (1 - (1 - f) * s); switch (i % 6) { case 0: r = v, g = t, b = p; break; case 1: r = q, g = v, b = p; break; case 2: r = p, g = v, b = t; break; case 3: r = p, g = q, b = v; break; case 4: r = t, g = p, b = v; break; case 5: r = v, g = p, b = q; break; } return `rgb(${Math.round(r * 255)}, ${Math.round(g * 255)}, ${Math.round(b * 255)})`; } function clamp01(x) { return x > 1 ? 1 : (x < 0 ? 0 : x); } function lerp(a,b, lerp) { lerp = clamp01(lerp); return a*(1-lerp)+b*lerp; } function intersectTwoLines(line1, line2) { var d = (line1.vx * line2.vy) - (line1.vy * line2.vx); if(d == 0) { return [null, null]; } var u1 = (line1.x1 * line1.y2 - line1.y1 * line1.x2); var u2 = (line2.x1 * line2.y2 - line2.y1 * line2.x2); var px = (u1 * line2.vx - line1.vx * u2) / d; var py = (u1 * line2.vy - line1.vy * u2) / d; return [ px, py ]; } function dot(v1x, v1y, v2x, v2y) { return v1x * v2x + v1y * v2y; } function magnitude(x, y) { return Math.sqrt(sqrMagnitude(x, y)); } function sqrMagnitude(x, y) { return x * x + y * y; } function normalize(x, y) { const m = magnitude(x, y); return [x/m, y/m]; } function getRandomPermutation (arr) { const perm = Array.apply(null, {length: arr.length}).map((_,i) => [Math.random(), i]).sort().map(v => v[1]); return arr.map((_, i) => arr[perm[i]]); } // https://stackoverflow.com/questions/7584794/accessing-jpeg-exif-rotation-data-in-javascript-on-the-client-side // function getJPEGEXIFOrientationFromFile(file, callback) { // var reader = new FileReader(); // reader.onload = function(e) { // var view = new DataView(e.target.result); // if (view.getUint16(0, false) != 0xFFD8) // { // return callback(-2); // } // var length = view.byteLength, offset = 2; // while (offset < length) // { // if (view.getUint16(offset+2, false) <= 8) return callback(-1); // var marker = view.getUint16(offset, false); // offset += 2; // if (marker == 0xFFE1) // { // if (view.getUint32(offset += 2, false) != 0x45786966) // { // return callback(-1); // } // var little = view.getUint16(offset += 6, false) == 0x4949; // offset += view.getUint32(offset + 4, little); // var tags = view.getUint16(offset, little); // offset += 2; // for (var i = 0; i < tags; i++) // { // if (view.getUint16(offset + (i * 12), little) == 0x0112) // { // return callback(view.getUint16(offset + (i * 12) + 8, little)); // } // } // } // else if ((marker & 0xFF00) != 0xFF00) // { // break; // } // else // { // offset += view.getUint16(offset, false); // } // } // return callback(-1); // }; // reader.readAsArrayBuffer(file); // } })(window, document, window.graffitiAppState);