PageSourceSearch

https://netizen.org/js/desktop.js

js netizen.org collected 2026-10-03 23:33:52 UTC 20,836 bytes, 590 lines download raw bytes

1/* global nn, NetizenNav, NetizenGrid, NetizenASCIISplash, NetizenContentBlock, NetizenBendPanel, NetizenBentText, NetizenSyrupImage, loadASCIIArt, loadData */
2
3window.data = null // contains all the content data
4let nav, splash, content, grid, bendPanel, subNav
5
6// default settings for processed images
7// TODO: make customizable via bend panel
8const syrupSettings = {
9  huffhack: {
10    process: false,
11    opacity: 0.66,
12    zIndex: 3,
13    seed: 7
14  },
15  dither: {
16    process: true,
17    opacity: 0.67,
18    zIndex: 2,
19    algorithm: 'atkinson',
20    dotSize: 1,
21    threshold: 174
22  },
23  pixelate: {
24    process: true,
25    opacity: 1,
26    zIndex: 1,
27    size: 8,
28    threshold: 128
29  },
30  ascii: {
31    process: true,
32    opacity: 0.5,
33    zIndex: 4,
34    chars: 'O*',
35    fontSize: 8,
36    fgColor: 'white'
37  }
38}
39
40// *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O
41// *.O                             util functions                          *.O
42// *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O
43
44function resetGridAndContent (exceptInitatives) {
45  grid.clearAllBlocks()
46  // clear all syrup images
47  nn.getAll('.syrup').forEach(ele => {
48    ele.classList.remove('syrup')
49    ele.querySelectorAll('img').forEach(img => img.remove())
50    ele.onmouseover = null
51    ele.onmouseout = null
52    ele.onclick = null
53  })
54  // remove any extra grids
55  nn.getAll('.grid').filter(d => d.id !== 'main-grid').forEach(d => d.remove())
56
57  if (exceptInitatives) return
58  // reset initiatives
59  subNav.classList.remove('sub-nav')
60  subNav.querySelectorAll('*').forEach(e => e.remove())
61  // revert center cell-content to original colors
62  content.ele.children[1].style.background = 'var(--prim-b)'
63  content.ele.children[1].style.color = 'var(--prim-a)'
64}
65
66function addSyrupImgToGrid (opts = {}) {
67  opts.ele1.classList.add('syrup')
68
69  const syrup = new NetizenSyrupImage({
70    image: opts.path,
71    ele: opts.ele1,
72    colors: [opts.ele1.style.backgroundColor, opts.ele2.style.backgroundColor],
73    huffhack: syrupSettings.huffhack,
74    dither: syrupSettings.dither,
75    pixelate: syrupSettings.pixelate,
76    ascii: syrupSettings.ascii
77  })
78
79  nn.create('img')
80    .set('src', opts.path)
81    .set('name', 'clean')
82    .addTo(opts.ele1)
83
84  const fadeIn = e => { e.style.opacity = 1 }
85  const fadeOut = e => { e.style.opacity = 0 }
86
87  opts.ele1.onmouseover = () => {
88    opts.ele1.querySelectorAll('.syrup-pre').forEach(fadeOut)
89    opts.ele1.querySelectorAll('.syrup-post').forEach(fadeIn)
90    opts.ele1.querySelectorAll('img').forEach(img => {
91      if (img.name !== 'clean') img.style.opacity = 0
92    })
93  }
94  opts.ele1.onmouseout = () => {
95    opts.ele1.querySelectorAll('.syrup-pre').forEach(fadeIn)
96    opts.ele1.querySelectorAll('.syrup-post').forEach(fadeOut)
97    opts.ele1.querySelectorAll('img').forEach(img => {
98      if (img.name !== 'clean') {
99        img.style.opacity = syrup[img.name].opacity
100      }
101    })
102  }
103
104  if (opts.callback) opts.ele1.onclick = () => opts.callback()
105}
106
107// *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O
108// *.O                  nav section change functions                       *.O
109// *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O
110
111// Below-the-fold initiative teasers on the home grid. Only initiatives with
112// real images in their own `grid` array can feature here (bias-out-of-the-box,
113// browserfest, and artware-incubator have none yet) -- same random-pick
114// pattern showInitiatives() already uses for D.R.E.A.M.'s own thumbnail.
115// goToPath is the same routine deep links and the splash credit use:
116// loadCaseStudy() assumes the initiatives sub-nav already exists in the DOM
117// (it looks up `.sub-nav-item[name=...]`), which is only true after
118// nav.updatePage('initiatives') has run and had time to render it -- calling
119// loadCaseStudy() straight from home throws on that lookup.
120//
121// Called from both showHome() and main()'s own "populate the initial grid"
122// block below: showHome() is guarded by `if (nav.state === 0) return`, which
123// is already true on a cold load (nav.state starts at 0), so it never runs on
124// first paint -- main() has always had its own separate, unguarded call for
125// exactly that reason (see the-crew.jpg being added in both places already).
126function populateHomeGrid () {
127  // WHICH initiatives feature on home is shared data (data/main.json
128  // "featuredOnHome" -- mobile reads the same key), so content changes only
129  // happen in one place. WHERE they land is desktop-specific: grid block
130  // slots, assigned positionally. Reading order per Jon: video first (block
131  // 2, the big top cell), projects between, NEWSLETTER (5) before BEND THIS
132  // SITE (6), cows (7), dream teaser (8). Two teaser slots means the third
133  // featuredOnHome entry only shows on mobile and the initiatives page --
134  // the trade for giving BEND its own block instead of it sharing block 1
135  // with a teaser whose syrup images painted right over the canvas.
136  const slots = [[1, 2], [8, 7]]
137  const featured = (window.data.featuredOnHome || []).slice(0, slots.length)
138  const featuredInitiatives = featured.map((name, i) =>
138 ({ name, blocks: slots[i] }))
139  featuredInitiatives.forEach(({ name, blocks }) => {
140    const init = window.data.initiatives[name]
141    const images = (init.grid || []).map(g => g.image)
142    if (!images.length) return
143    addSyrupImgToGrid({
144      path: nn.random(images),
145      ele1: grid.getBlock(blocks[0]),
146      ele2: grid.getBlock(blocks[1]),
147      callback: () => goToPath('/initiatives/' + name)
148    })
149  })
150
151  // Newsletter CTA: same graphical-lettering treatment as BEND THIS SITE
152  // (NetizenBentText, stroke colour borrowed from a neighbouring cell --
153  // the convention bendPanel established). Block 5. Re-entering home
154  // re-populates the grid, so destroy the previous instance first or its
155  // ResizeObserver keeps watching a detached canvas.
156  if (window.data.newsletterUrl) {
157    if (window._newsletterText) window._newsletterText.destroy()
158    window._newsletterText = new NetizenBentText({
159      parent: grid.getBlock(5),
160      text: ['NEWS', 'LETTER'],
161      color: grid.getBlock(6).style.backgroundColor,
162      padding: 10,
163      xOverlap: 30,
164      yOverlap: 80
165    })
166    window._newsletterText.canvas.style.cursor = 'pointer'
167    window._newsletterText.canvas.onclick = () =>
168      window.open(window.data.newsletterUrl, '_blank')
169  }
170
171  // Feature video (new release spotlight), block 2 -- the first big element.
172  // Same syrup treatment as every other grid image (addSyrupImgToGrid),
173  // instead of a plain thumbnail, for visual consistency with the rest of
174  // the grid. Paired with block 1 for its second colour -- the reverse of
175  // the pairing populateHomeGrid() already uses when block 1 hosts a
176  // teaser (ele1: block 1, ele2: block 2).
177  //
178  // hqdefault.jpg (not maxresdefault.jpg) because it's guaranteed to exist
179  // for every video -- maxresdefault silently 200s with a tiny placeholder
180  // instead of 404ing when it's missing, so there'd be no reliable way to
181  // detect and fall back.
182  if (window.data.featureVideo && window.data.featureVideo.youtubeId) {
183    const v = window.data.featureVideo
184    const block = grid.getBlock(2)
185    block.classList.add('feature-video')
186    addSyrupImgToGrid({
187      path: `https://img.youtube.com/vi/${v.youtubeId}/hqdefault.jpg`,
188      ele1: block,
189      ele2: grid.getBlock(1),
190      callback: () => window.open(`https://www.youtube.com/watch?v=${v.youtubeId}`, '_blank', 'noopener')
191    })
192    // a CSS background-image, not an <img> -- addSyrupImgToGrid's hover
193    // handlers do querySelectorAll('img') and assume every non-"clean" img
194    // has a matching entry in `syrup` (huffhack/pixelate/dither/ascii); an
195    // actual <img> here would hit that and throw on mouseout
196    nn.create('span').set('class', 'play-icon').addTo(block)
197  }
198
199  // BEND THIS SITE lives in block 6 now (its own block -- reading order puts
200  // it after the newsletter). resetGridAndContent()'s clearAllBlocks() strips
201  // every block's children on each home re-entry, bend canvas included, so
202  // re-home it here. Re-pointing .container keeps NetizenBentText's sizing
203  // honest before the redraw.
204  if (typeof bendPanel !== 'undefined' && bendPanel && bendPanel.text) {
205    const bendBlock = grid.getBlock(6)
206    if (bendPanel.text.canvas.parentNode !== bendBlock) {
207      bendPanel.text.container = bendBlock
208      bendBlock.appendChild(bendPanel.text.canvas)
209      bendPanel.text.update()
210    }
211  }
212}
213
214function showHome () {
215  if (nav.state === 0) return
216
217  if (content.showing) {
218    content.hide()
219    splash.start()
220  }
221
222  resetGridAndContent()
223
224  addSyrupImgToGrid({
225    path: 'images/the-crew.jpg',
226    ele1: grid.getBlock(4),
227    ele2: grid.getBlock(3),
228    callback: () => nav.updatePage('about')
229  })
230
231  loadASCIIArt({
232    path: 'ascii-art/cows.txt',
233    parent: grid.getBlock(7),
234    link: 'http://www.textfiles.com/art/cow.txt'
235  })
236
237  populateHomeGrid()
238
239  grid.transitionTo(window.grids.default)
240
241  if (bendPanel.showing) bendPanel.hidePanel()
242  bendPanel.enable()
243}
244
245// --------------------------------------------------------------- ABOUT SECTION
246function showAbout () {
247  if (nav.state === 'about') return
248
249  resetGridAndContent()
250
251  content.show('about')
252
253  splash.stop()
254
255  grid.transitionTo(window.grids.default)
256
257  loadASCIIArt({
258    path: 'ascii-art/cows.txt',
259    parent: grid.getBlock(7),
260    link: 'http://www.textfiles.com/art/cow.txt'
261  })
262
263  nn.create('img')
264    .set('src', 'images/the-crew.jpg')
265    .css({ cursor: 'auto' })
266    .addTo(grid.getBlock(4))
267}
268
269// --------------------------------------------------------- INITIATIVES SECTION
270async function showInitiatives () {
271  if (nav.state === 'initiatives') return
272  content.show('initiatives')
273
274  resetGridAndContent()
275
276  subNav.classList.add('sub-nav')
277  window.data._initiativesOrder.forEach(proj => {
278    const key = proj.split('.')[0]
279    const obj = window.data.initiatives[key]
280    const item = nn.create('span')
281      .content(obj.title)
282      .set('class', 'sub-nav-item')
283      .set('name', key)
284      .addTo(subNav)
285      .on('click', () => window.loadCaseStudy(obj))
286    setTimeout(() => { item.style.opacity = 1 }, 900)
287  })
288
289  grid.transitionTo(window.grids.default2)
290
291  const dreamPics = [1, 2, 3, 7, 9, 12]
292  const dreamObj = window.data.initiatives.dream
293  addSyrupImgToGrid({
294    path: `images/dream/dream${nn.random(dreamPics)}.jpg`,
295    ele1: grid.getBlock(3),
296    ele2: grid.getBlock(4),
297    callback: () => window.loadCaseStudy(dreamObj)
298  })
299
300  const ascii = await loadASCIIArt({
301    path: 'ascii-art/netnet.txt',
302    parent: grid.getBlock(1),
303    css: { fontSize: '2rem', cursor: 'pointer' }
304  })
305  const nnObj = window.data.initiatives.netnet
306  ascii.onclick = () => window.loadCaseStudy(nnObj)
307}
308
309// ------------------------------------------------------------- SUPPORT SECTION
310function showSupport () {
311  if (nav.state === 'support') return
312  resetGridAndContent()
313  content.show('support')
314
315  grid.transitionTo(window.grids.default)
316
317  const asciiBlock = grid.grid === window.grids.default
318    ? grid.getBlock(4) : grid.getBlock(5)
319  loadASCIIArt({
320    path: 'ascii-art/netizen-heart.txt',
321    parent: asciiBlock,
322    css: { letterSpacing: '0.75rem' }
323  })
324}
325
326// *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O
327// *.O                             main function                           *.O
328// *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O *.O
329
330// Attribution for whatever the splash is currently showing.
331// NetizenASCIISplash advances its own image on click and exposes no event, so
332// this polls imagePath rather than reaching into the class. Cheap, and it keeps
333// the credit out of that component entirely.
334function setupSplashCredit () {
335  const ele = nn.get('#splash-credit')
336  if (!ele) return
337  // sit just above the bottom edge of the splash canvas
338  const place = () => {
339    const h = (splash && splash.canvas) ? splash.canvas.getBoundingClientRect().height : 0
340    if (h) ele.style.top = Math.round(h - 30) + 'px'
341  }
342  place()
343  window.addEventListener('resize', place)
344
345  // The credit is position:fixed, so on its own it would sit in the viewport's
346  // bottom right forever -- following you down the page and lingering on
347  // interior sections. It belongs to the splash, so it should only be visible
348  // when the splash is what you're actually looking at: at the top of the
349  // page, and on the home section.
350  //
351  // Gated on nav.state (0 on home, the section name otherwise) rather than
352  // #splash-canvas opacity. Opacity seemed reasonable at first -- NetizenNav
353  // fades the canvas to 0 on interior pages -- but prototypes/splash-lab.js's
354  // "keep splash alive on interior pages" feature deliberately forces that
355  // same opacity back to 1, since the whole point of that feature is for the
356  // reveal to keep running behind About/Support/Initiatives. Two of our own
357  // features were fighting over one signal. nav.state is set synchronously by
358  // NetizenNav.updatePage() no matter what any prototype does to the canvas,
359  // so it can't be fought over the same way.
360  let shown = null
361  let resolved = false
362  const visible = () => {
363    if (!resolved) return false
364    if (window.scrollY > 30) return false
365    return !nav || nav.state === 0
366  }
367  const updateVisibility = () => {
368    const on = visible()
369    ele.style.opacity = on ? '1' : '0'
370    ele.style.pointerEvents = on ? 'auto' : 'none'
371  }
372  window.addEventListener('scroll', updateVisibility, { passive: true })
373
374  // React the instant nav changes, not just on the next 400ms poll --
375  // separate from the fact that opacity-based timing is gone now too.
376  if (nav) {
377    const origActivate = nav._activate.bind(nav)
378    nav._activate = function (sec) { origActivate(sec); updateVisibility() }
379    const origDeactivate = nav._deactivate.bind(nav)
380    nav._deactivate = function () { origDeactivate(); updateVisibility() }
381  }
382
383  const render = () => {
384    place()
385    const src = splash && splash.imagePath
386    // initiatives load async, so a lookup can legitimately come back empty on
387    // the first pass. Only stop re-checking once we've actually resolved one --
388    // caching on src alone would pin the very first (empty) answer forever.
389    if (src !== shown || !resolved) {
390      shown = src
391      const c = window.splashCreditForSrc(src)
392      if (!c) {
393        ele.innerHTML = ''; ele.hidden = true; resolved = false
394      } else {
395        resolved = true
396        ele.hidden = false
397        ele.innerHTML = c.href
398          ? `<a href="${c.href}">
398${c.label}</a>`
399          : `<span>${c.label}</span>`
400        // href="/initiatives/dream" would otherwise trigger a real page
401        // navigation on click (pushState-based routing has no native
402        // equivalent of the old hash-anchor trick). Drive it through the
403        // same in-app router URLHandling() uses on a cold load instead.
404        const a = ele.querySelector('a')
405        if (a) a.addEventListener('click', e => { e.preventDefault(); goToPath(c.href) })
406      }
407    }
408    updateVisibility()   // also catches nav changes, which fade the canvas
409  }
410  render()
411  setInterval(render, 400)
412}
413
414// Shared by the cold-load path handler below, by in-page links (the splash
415// credit) that need the same routing but can't rely on a real navigation,
416// and by the popstate listener that drives browser back/forward.
417async function goToPath (path) {
418  const clean = path.replace(/^\/+/, '') // '/initiatives/dream' -> 'initiatives/dream'
419  if (clean.startsWith('about')) {
420    nav.updatePage('about')
421  } else if (clean.startsWith('initiatives')) {
422    nav.updatePage('initiatives')
423    const parts = clean.split('/')
424    if (parts.length > 1) {
425      await nn.sleep(1200)
426      const item = nn.get(`.sub-nav-item[name="${parts[1]}"]`)
427      if (item) item.click()
428    }
429  } else if (clean.startsWith('support')) {
430    nav.updatePage('support')
431  } else if (nav.state !== 0) {
432    nav.updatePage('logo') // back/forward landed on '/'
433  }
434}
435
436async function URLHandling () {
437  return goToPath(window.location.pathname)
438}
439
440// window.location.pathname has already moved by the time this fires (that's
441// how popstate works), so goToPath -> nav.updatePage's own pathname check
442// keeps this from pushing a duplicate history entry.
443window.addEventListener('popstate', () => goToPath(window.location.pathname))
444
445function resize () {
446  if (window.blockerTimer) {
447    clearTimeout(window.blockerTimer)
448    clearTimeout(window.killLoader)
449  }
450
451  if (nn.width < 900) bendPanel.disable()
452  else bendPanel.enable()
453
454  nn.get('#loader').css('display', 'flex')
455  nn.get('#loader').css('opacity', 1)
456
457  content.adjustHeight()
458
459  window.blockerTimer = setTimeout(() => {
460    nn.get('#loader').css('opacity', 0)
461    window.killLoader = setTimeout(() => nn.get('#loader').css('display', 'none'), 800)
462  }, 250)
463}
464
465async function main () {
466  if (nn.isMobile()) {
467    // absolute, not relative: with real paths now (e.g. /initiatives/dream)
468    // a relative 'mobile.html' would resolve to /initiatives/mobile.html.
469    // Carry the intended path along so mobile.js can land on the same
470    // section instead of silently bouncing every deep link back to home.
471    const path = window.location.pathname
472    window.location = path && path !== '/'
473      ? `/mobile.html?redirect=${encodeURIComponent(path)}`
474      : '/mobile.html'
475    return
476  }
477
478  // load initial data
479  window.data = await loadData()
480
481  // setup the nav menu
482  nav = new NetizenNav({
483    parent: 'header',
484    logo: '#netizen-logo',
485    onSelection: (sec) => {
486      switch (sec) {
487        case 'logo': showHome(); break
488        case 'about': showAbout(); break
489        case 'initiatives': showInitiatives(); break
490        case 'support': showSupport(); break
491      }
492    }
493  })
494
495  // setup splash page slide show
496  splash = new NetizenASCIISplash({
497    fontSize: 16,
498    ele: nn.get('#splash-canvas'),
499    radius: 200,
500    duration: 1000,
501    images: nn.shuffle(window.data.splashImages.map(window.splashSrc))
502  })
503
504  setupSplashCredit()
505
506  // setup content section (displays over the splash + above the grid)
507  content = new NetizenContentBlock()
508  subNav = content.ele.children[0]
509  subNav.classList.add('sub-nav')
510
511  // setup main grid section
512  grid = new NetizenGrid({
513    selector: '#main-grid',
514    grid: window.grids.default
515  })
516
517  // setup bend panel
518  await document.fonts.load('200px Fira Mono')
519  bendPanel = new NetizenBendPanel({
520    textEle: grid.getBlock(6),
521    panelEle: '.grid div:nth-child(3)', // 'main',
522    onShow: () => {
523      const t = nn.get('.grid').top + window.pageYOffset - nn.get('header').height
524      window.scrollTo({ top: t, behavior: 'smooth' })
525      grid.transitionTo(window.grids.default2)
526      // remove the crew fromt the grid
527      nn.getAll('.syrup').forEach(ele => {
528        ele.classList.remove('syrup')
529        ele.querySelectorAll('img').forEach(img => img.remove())
530        ele.onmouseover = null
531        ele.onmouseout = null
532        ele.onclick = null
533      })
534    },
535    onHide: () => {
536      grid.transitionTo(window.grids.default)
537      // add crew back to grid
538      addSyrupImgToGrid({
539        path: 'images/the-crew.jpg',
540        ele1: grid.getBlock(4),
541        ele2: grid.getBlock(3),
542        callback: () => nav.updatePage('about')
543      })
544    }
545  })
546
547  if (nn.width < 900) bendPanel.disable()
548
549  // popualte the inital grid
550  loadASCIIArt({
551    path: 'ascii-art/cows.txt',
552    parent: grid.getBlock(7),
553    link: 'http://www.textfiles.com/art/cow.txt'
554  })
555
556  addSyrupImgToGrid({
557    path: 'images/the-crew.jpg',
558    ele1: grid.getBlock(4),
559    ele2: grid.getBlock(3),
560    callback: () => nav.updatePage('about')
561  })
562
563  window.scrollTo({ top: 0, behavior: 'smooth' })
564  await nn.sleep(2000)
565
566  // check for window location logic
567  const total = window.data._initiativesOrder.length
568  const waitingForDataToLoad = setInterval(async () => {
569    const loaded = Object.keys(window.data.initiatives).length
570    if (loaded === total) { // if data is all loaded
571      clearInterval(waitingForDataToLoad)
572
573      // loadData() kicks off each initiative's fetch without awaiting it (a
574      // forEach with an async callback doesn't block), so window.data.initiatives
575      // is still empty immediately after the crew-photo setup above -- this
576      // loop existing at all is the tell. populateHomeGrid() needs real
577      // `.grid` arrays per initiative, so it waits here too.
578      populateHomeGrid()
579
580      await URLHandling()
581
582      nn.get('#loader').css('opacity', 0)
583      setTimeout(() => nn.get('#loader').css('display', 'none'), 800)
584    }
585    // ... otherwise, keep looping
586  }, 200)
587}
588
589nn.on('load', main)
590nn.on('resize', resize)

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.