Compare commits

...

26 Commits

Author SHA1 Message Date
Mike Lynch 10f4f0b2c6 Added a brief explanatory line 2025-02-03 14:54:30 +11:00
bombinans 3837d6d9fa Merge pull request 'rc-1.1.1' (#23) from rc-1.1.1 into main
Reviewed-on: #23
2025-01-27 07:55:49 +00:00
bombinans 6c27e9fceb Merge branch 'main' into rc-1.1.1 2025-01-27 07:55:40 +00:00
Mike Lynch 1b89420c6b Updated Readme again 2025-01-27 18:54:29 +11:00
Mike Lynch 3ca5e2e2c7 Link to image on README 2025-01-27 18:52:49 +11:00
bombinans 78e696e7e9 Merge pull request 'rc-1.1.1 - pulling to get the sample image onto main' (#22) from rc-1.1.1 into main
Reviewed-on: #22
2025-01-27 07:51:19 +00:00
Mike Lynch 35edbce333 Added sample pattern 2025-01-27 18:48:15 +11:00
Mike Lynch d44ee2bb71 Added link to live version to README.md 2025-01-27 18:46:42 +11:00
Mike Lynch 72d50ca58a Merge branch 'feature-bot-script' into rc-1.1.1 2025-01-27 18:44:41 +11:00
Mike Lynch 224e1180d1 Added basic command-line flags for width and output file name 2025-01-27 16:04:26 +11:00
Mike Lynch 54adcacdb1 Random PNG palettes and patterns are working 2025-01-27 15:37:22 +11:00
Mike Lynch 29ed3a7bca Generates PNG using resvg-js 2025-01-27 15:14:06 +11:00
Mike Lynch 2c2956a214 npm installing a bunch of dependencies so that they can work with the
command-line script and not just get bundled at build time
2025-01-26 18:03:42 +11:00
Mike Lynch d2c38a4053 Broke out the colour stuff to its own component 2025-01-26 18:00:14 +11:00
Mike Lynch 6fc02a1f3e Standalone script generates SVG 2025-01-26 17:26:17 +11:00
Mike Lynch 7fd2c3657e Partial fix for flashing 2025-01-26 17:03:35 +11:00
Mike Lynch 568cb90fd5 Broken version with standalone script 2025-01-16 10:45:06 +11:00
bombinans 773abb4d36 Merge pull request 'Put the wasm load in its own code block' (#18) from feature-downloads into main
Reviewed-on: #18
2025-01-11 06:52:04 +00:00
Mike Lynch 54a7ceda33 Put the wasm load in its own code block 2025-01-11 17:51:02 +11:00
bombinans 7791aee721 Merge pull request 'feature-downloads' (#17) from feature-downloads into main
Reviewed-on: #17
2025-01-11 06:44:52 +00:00
Mike Lynch a02252d8fb Moved the resvg code into downloads, made both svg and png work the
same way, added a note crediting resvg
2025-01-11 17:43:01 +11:00
Mike Lynch 146894583b First working version of wasm svg->png generator 2025-01-11 17:18:43 +11:00
Mike Lynch daa4bbf9b3 Added comment indicating where I got the download code from 2025-01-11 15:33:50 +11:00
Mike Lynch d7bccd620e download code 2025-01-11 15:32:35 +11:00
Mike Lynch 0a53220ecb Basic download SVG 2025-01-11 15:32:18 +11:00
bombinans 4224cb1b3b Merge pull request 'feature-more-radius-fns' (#16) from feature-more-radius-fns into main
Reviewed-on: #16
2025-01-09 23:36:57 +00:00
10 changed files with 1319 additions and 181 deletions

View File

@ -1,5 +1,13 @@
# CHANGELOG.md # CHANGELOG.md
## v1.1.1
Made the flashing transitions a bit better, but it still needs more work
## v1.1.0
Added SVG and PNG downloads
## v1.0.2 ## v1.0.2
* Fixed bug which was stopping slanted grids * Fixed bug which was stopping slanted grids

View File

@ -3,7 +3,11 @@
An experiment in using Observable to generate colourful SVG patterns An experiment in using Observable to generate colourful SVG patterns
reminiscent of op art or halftone dots. reminiscent of op art or halftone dots.
Comments, feedback via Mastodon: [Mike Lynch](https://aus.social/@mikelynch) You can play with it [here.](https://etc.mikelynch.org/poptimal/)
![Two overlapping grids of purple and lime dots on a teal background](https://git.tilde.town/bombinans/poptimal/raw/branch/main/docs/sample.png)
Comments, feedback via Mastodon: [Mike Lynch](https://old.mermaid.town/@fsvo)
## Observable ## Observable
@ -21,46 +25,3 @@ npm run dev
Then visit <http://localhost:3000> to preview your app. Then visit <http://localhost:3000> to preview your app.
For more, see <https://observablehq.com/framework/getting-started>.
## Project structure
A typical Framework project looks like this:
```ini
.
├─ src
│ ├─ components
│ │ └─ timeline.js # an importable module
│ ├─ data
│ │ ├─ launches.csv.js # a data loader
│ │ └─ events.json # a static data file
│ ├─ example-dashboard.md # a page
│ ├─ example-report.md # another page
│ └─ index.md # the home page
├─ .gitignore
├─ observablehq.config.js # the app config file
├─ package.json
└─ README.md
```
**`src`** - This is the “source root” — where your source files live. Pages go here. Each page is a Markdown file. Observable Framework uses [file-based routing](https://observablehq.com/framework/project-structure#routing), which means that the name of the file controls where the page is served. You can create as many pages as you like. Use folders to organize your pages.
**`src/index.md`** - This is the home page for your app. You can have as many additional pages as youd like, but you should always have a home page, too.
**`src/data`** - You can put [data loaders](https://observablehq.com/framework/data-loaders) or static data files anywhere in your source root, but we recommend putting them here.
**`src/components`** - You can put shared [JavaScript modules](https://observablehq.com/framework/imports) anywhere in your source root, but we recommend putting them here. This helps you pull code out of Markdown files and into JavaScript modules, making it easier to reuse code across pages, write tests and run linters, and even share code with vanilla web applications.
**`observablehq.config.js`** - This is the [app configuration](https://observablehq.com/framework/config) file, such as the pages and sections in the sidebar navigation, and the apps title.
## Command reference
| Command | Description |
| ----------------- | -------------------------------------------------------- |
| `npm install` | Install or reinstall dependencies |
| `npm run dev` | Start local preview server |
| `npm run build` | Build your static site, generating `./dist` |
| `npm run deploy` | Deploy your app to Observable |
| `npm run clean` | Clear the local data loader cache |
| `npm run observable` | Run commands like `observable help` |

BIN
docs/sample.png 100644

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

979
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -10,8 +10,15 @@
}, },
"dependencies": { "dependencies": {
"@observablehq/framework": "^1.13.0", "@observablehq/framework": "^1.13.0",
"@resvg/resvg-js": "^2.6.2",
"d3": "^7.9.0",
"d3-color": "^3.1.0",
"d3-dsv": "^3.0.1", "d3-dsv": "^3.0.1",
"d3-time-format": "^4.1.0" "d3-time-format": "^4.1.0",
"jsdom": "^26.0.0",
"lodash.shuffle": "^4.2.0",
"random": "^5.1.1",
"yargs": "^17.7.2"
}, },
"devDependencies": { "devDependencies": {
"rimraf": "^5.0.5" "rimraf": "^5.0.5"

113
poptimal.js 100644
View File

@ -0,0 +1,113 @@
import { Resvg } from "@resvg/resvg-js";
import { promises } from "fs";
import { JSDOM } from "jsdom";
import * as d3 from "d3";
import yargs from "yargs/yargs";
import { hideBin } from "yargs/helpers";
import random from "random";
const xmlns = "http://www.w3.org/2000/xmlns/";
const xlinkns = "http://www.w3.org/1999/xlink";
const svgns = "http://www.w3.org/2000/svg";
import {RADIUS_OPTS, DotMaker} from './src/components/dots.js';
import {PALETTES} from './src/components/palettes.js';
const CELL = 10;
const MAG = 2;
const WIDTH = 20;
const HEIGHT = WIDTH;
function poptimal_svg() {
const window = new JSDOM().window;
const document = window.document;
const container = d3.select(document.body).append("div");
const dm = new DotMaker(WIDTH);
const m1 = random.choice([1, 2, 3, 4, 5]);
const n1 = random.choice([1, 2, 3, 4, 5]);
const m2 = random.choice([1, 2, 3, 4, 5]);
const n2 = random.choice([1, 2, 3, 4, 5]);
const palette_fn = random.choice(Array.from(PALETTES.values()));
const palette = palette_fn();
const bg = palette[0];
const fg1 = palette[1];
const fg2 = palette[2];
const f1 = random.choice(RADIUS_OPTS);
const f2 = random.choice(RADIUS_OPTS);
const r1 = random.float(0, 0.4);
const r2 = random.float(0, 0.4);
const dots1 = dm.dots(1 / m1, n1);
const dots2 = dm.dots(1 / m2, n2);
const svg = container.append("svg")
.attr("width", WIDTH * CELL * MAG)
.attr("height", HEIGHT * CELL * MAG)
.attr("viewBox", [ 0, 0, WIDTH, HEIGHT ]);
const background = svg.append("rect")
.attr("x", 0)
.attr("y", 0)
.attr("width", WIDTH)
.attr("height", WIDTH)
.attr("fill", bg);
const dots_g1 = svg.append("g")
.attr("id", "dots1");
dots_g1.selectAll("circle")
.data(dots1)
.join("circle")
.attr("r", (d) => dm.radius(d, f1, r1))
.attr("fill", fg1)
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y);
const dots_g2 = svg.append("g")
.attr("id", "dots2");
dots_g2.selectAll("circle")
.data(dots2)
.join("circle")
.attr("r", (d) => dm.radius(d, f2, r2))
.attr("fill", fg2)
.attr("cx", (d) => d.x)
.attr("cy", (d) => d.y);
const node = svg.node();
node.setAttributeNS(xmlns, "xmlns", svgns);
node.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
const serializer = new window.XMLSerializer;
return serializer.serializeToString(node);
}
async function main() {
const argv = yargs(hideBin(process.argv))
.usage("Usage: -w WIDTH -o OUTPUT_PNG")
.default('w', 1200)
.default('o', 'poptimal.png').argv;
const svg = poptimal_svg(argv.w);
const opts = {
background: 'rgba(255, 255, 255, 1.0)',
fitTo: {
mode: 'width',
value: argv.w,
},
};
const resvg = new Resvg(svg, opts)
const pngData = resvg.render()
const pngBuffer = pngData.asPng()
await promises.writeFile(argv.o, pngBuffer);
}
main();

View File

@ -2,90 +2,6 @@
import * as Inputs from "npm:@observablehq/inputs"; import * as Inputs from "npm:@observablehq/inputs";
import random from "npm:random"; import random from "npm:random";
import shuffle from "npm:lodash.shuffle";
import * as d3 from "npm:d3-color";
const PALETTES = new Map([
[ "random RGB", palette_random ],
[ "grayscale", palette_grayscale ],
[ "monochrome", palette_monochrome ],
[ "one spot", palette_one_spot ],
[ "triad", triad_saturated ],
[ "triad pastel", triad_pastel ],
[ "triad dusk", triad_dusk ],
[ "RGB", palette_rgb ],
[ "RBY", palette_rby ],
[ "CMY", palette_cmy ],
]);
// two spot
// RGB
// CMY
// RYB
// triad - full saturation
// triad - pastel
// trial - dusk
// random HSV
// random RGB
function palette_random() {
const u = random.uniform(0, 255);
return [1,2,3].map((x)=> d3.rgb(u(), u(), u()));
}
function palette_grayscale() {
const u = random.uniform(0, 1);
return [1,2,3].map((x)=> d3.hsl(0, 0, u()));
}
function palette_monochrome() {
const u = random.uniform(0, 1);
const h = u() * 360;
return [1,2,3].map((x)=> d3.hsl(h, u(), u()));
}
function palette_one_spot() {
const hue = random.uniform(0, 360);
const cols = [ d3.color("white"), d3.color("black"), d3.hsl(hue(), 1, 0.5) ]
return shuffle(cols);
}
function triad_saturated() {
return triad(1, 0.5);
}
function triad_pastel() {
return triad(0.6, 0.7);
}
function triad_dusk() {
return triad(1, 0.25);
}
function triad(s, l) {
const u = random.uniform(0, 360);
const h1 = u();
const h2 = (h1 + 120) % 360;
const h3 = (h1 + 240) % 360;
const cols = [ h1, h2, h3 ].map((h) => d3.hsl(h, s, l));
return shuffle(cols);
}
function palette_rgb() {
const cols = [ d3.rgb(255, 0, 0), d3.rgb(0, 255, 0), d3.rgb(0, 0, 255) ];
return shuffle(cols);
}
function palette_rby() {
const cols = [ d3.rgb(255, 0, 0), d3.rgb(255, 255, 0), d3.rgb(0, 0, 255) ];
return shuffle(cols);
}
function palette_cmy() {
const cols = [ d3.rgb(0, 255, 255), d3.rgb(255, 255, 0), d3.rgb(255, 0, 255) ];
return shuffle(cols);
}
class DotControls { class DotControls {
@ -110,8 +26,10 @@ class DotControls {
this.f.dispatchEvent(new Event("input")); this.f.dispatchEvent(new Event("input"));
} }
random_colours() { null_grid() {
this.set_colour(random_colour()); // set to zero to stop flashing when randomising all
this.r.value = 0;
this.r.dispatchEvent(new Event("input"));
} }
set_colour(c) { set_colour(c) {
@ -122,4 +40,4 @@ class DotControls {
} }
export { DotControls, PALETTES }; export { DotControls };

View File

@ -0,0 +1,84 @@
import * as resvg from 'npm:@resvg/resvg-wasm';
const xmlns = "http://www.w3.org/2000/xmlns/";
const xlinkns = "http://www.w3.org/1999/xlink";
const svgns = "http://www.w3.org/2000/svg";
// adapted from the DOM.download method as per this issue:
// https://github.com/observablehq/framework/issues/906
export function download(value, name = "untitled", label = "Save") {
const a = document.createElement("a");
const b = a.appendChild(document.createElement("button"));
b.textContent = label;
a.download = name;
async function reset() {
await new Promise(requestAnimationFrame);
URL.revokeObjectURL(a.href);
a.removeAttribute("href");
b.textContent = label;
b.disabled = false;
}
a.onclick = async event => {
console.log("clicked download");
b.disabled = true;
if (a.href) {
console.log(`already saved: ${a.href}`);
return reset(); // Already saved.
}
b.textContent = "Saving…";
try {
console.log("awaiting value function");
const object = await (typeof value === "function" ? value() : value);
console.log(object);
b.textContent = "Download";
a.href = URL.createObjectURL(object); // eslint-disable-line require-atomic-updates
console.log(`url = ${a.href}`);
} catch (ignore) {
b.textContent = label;
}
if (event.eventPhase) return reset(); // Already downloaded.
b.disabled = false;
};
return a;
}
export function svg_to_string(svg) {
svg = svg.cloneNode(true);
svg.setAttributeNS(xmlns, "xmlns", svgns);
svg.setAttributeNS(xmlns, "xmlns:xlink", xlinkns);
const serializer = new window.XMLSerializer;
return serializer.serializeToString(svg);
}
export function download_as_svg(svg) {
const str = svg_to_string(svg);
return new Blob([str], {type: "image/svg+xml"})
}
export async function download_as_png (svg) {
// The Wasm must be initialized first
const svgstr = svg_to_string(svg);
const opts = {
fitTo: {
mode: 'width', // If you need to change the size
value: 400,
}
};
const resvgJS = new resvg.Resvg(svgstr, opts)
const pngData = resvgJS.render(svgstr, opts)
const pngBuffer = pngData.asPng();
return new Blob([pngBuffer], { type: 'image/png' });
}

View File

@ -0,0 +1,78 @@
import * as d3 from "d3-color";
import shuffle from "lodash.shuffle";
import random from "random";
const PALETTES = new Map([
[ "random RGB", palette_random ],
[ "grayscale", palette_grayscale ],
[ "monochrome", palette_monochrome ],
[ "one spot", palette_one_spot ],
[ "triad", triad_saturated ],
[ "triad pastel", triad_pastel ],
[ "triad dusk", triad_dusk ],
[ "RGB", palette_rgb ],
[ "RBY", palette_rby ],
[ "CMY", palette_cmy ],
]);
function palette_random() {
const u = random.uniform(0, 255);
return [1,2,3].map((x)=> d3.rgb(u(), u(), u()));
}
function palette_grayscale() {
const u = random.uniform(0, 1);
return [1,2,3].map((x)=> d3.hsl(0, 0, u()));
}
function palette_monochrome() {
const u = random.uniform(0, 1);
const h = u() * 360;
return [1,2,3].map((x)=> d3.hsl(h, u(), u()));
}
function palette_one_spot() {
const hue = random.uniform(0, 360);
const cols = [ d3.color("white"), d3.color("black"), d3.hsl(hue(), 1, 0.5) ]
return shuffle(cols);
}
function triad_saturated() {
return triad(1, 0.5);
}
function triad_pastel() {
return triad(0.6, 0.7);
}
function triad_dusk() {
return triad(1, 0.25);
}
function triad(s, l) {
const u = random.uniform(0, 360);
const h1 = u();
const h2 = (h1 + 120) % 360;
const h3 = (h1 + 240) % 360;
const cols = [ h1, h2, h3 ].map((h) => d3.hsl(h, s, l));
return shuffle(cols);
}
function palette_rgb() {
const cols = [ d3.rgb(255, 0, 0), d3.rgb(0, 255, 0), d3.rgb(0, 0, 255) ];
return shuffle(cols);
}
function palette_rby() {
const cols = [ d3.rgb(255, 0, 0), d3.rgb(255, 255, 0), d3.rgb(0, 0, 255) ];
return shuffle(cols);
}
function palette_cmy() {
const cols = [ d3.rgb(0, 255, 255), d3.rgb(255, 255, 0), d3.rgb(255, 0, 255) ];
return shuffle(cols);
}
export { PALETTES }

View File

@ -2,9 +2,13 @@
toc: false toc: false
--- ---
<h1>poptimal</h1> <h1>poptimal</h1>
<p>v1.0.2 | by <a href="https://mikelynch.org">mike lynch</a> | <a href="https://aus.social/@mikelynch">@mikelynch@aus.social</a> | <a href="https://git.tilde.town/bombinans/poptimal">source</a></p> colourful generative patterns using [d3](https://d3js.org/) and [Observable Framework](https://observablehq.com/framework/)
<p>v1.1.1 | by <a href="https://mikelynch.org">mike lynch</a> | <a href="https://aus.social/@mikelynch">@mikelynch@aus.social</a> | <a href="https://git.tilde.town/bombinans/poptimal">source</a></p>
<div class="grid grid-cols-2"> <div class="grid grid-cols-2">
@ -13,9 +17,13 @@ toc: false
```js ```js
import {RADIUS_OPTS, DotMaker} from './components/dots.js'; import {RADIUS_OPTS, DotMaker} from './components/dots.js';
import {PALETTES, DotControls} from './components/controls.js'; import {DotControls} from './components/controls.js';
import {PALETTES} from './components/palettes.js';
import {download, download_as_svg, download_as_png} from './components/download.js';
import random from "npm:random"; import random from "npm:random";
import * as resvg from 'npm:@resvg/resvg-wasm';
const CELL = 10; const CELL = 10;
const MAG = 2; const MAG = 2;
const WIDTH = 20; const WIDTH = 20;
@ -52,8 +60,14 @@ const randomise_palette = view(Inputs.button("Random", {label:"palette"}));
const randomise_all = view(Inputs.button("Random", {label:"all"})); const randomise_all = view(Inputs.button("Random", {label:"all"}));
```
```js
// in its own code block so that it doesn't slow down load for the rest
// of the page
await resvg.initWasm(fetch('https://unpkg.com/@resvg/resvg-wasm/index_bg.wasm'));
const wasm_init = "ready";
``` ```
@ -68,11 +82,13 @@ ctrl2.random_grid();
```js ```js
randomise_all; randomise_all;
ctrl1.random_grid();
ctrl2.random_grid();
const rpalette = random.choice(Array.from(PALETTES.keys())); const rpalette = random.choice(Array.from(PALETTES.keys()));
ctrl1.null_grid();
ctrl2.null_grid();
palette_input.value = PALETTES.get(rpalette); palette_input.value = PALETTES.get(rpalette);
palette_input.dispatchEvent(new Event("input")); palette_input.dispatchEvent(new Event("input"));
ctrl1.random_grid();
ctrl2.random_grid();
``` ```
@ -109,6 +125,7 @@ const dots2 = dm.dots(1 / m2, n2);
``` ```
```js ```js
// Set up the svg canvas
const svg = d3.create("svg") const svg = d3.create("svg")
@ -116,12 +133,29 @@ const svg = d3.create("svg")
.attr("height", HEIGHT * CELL * MAG) .attr("height", HEIGHT * CELL * MAG)
.attr("viewBox", [ 0, 0, WIDTH, HEIGHT ]); .attr("viewBox", [ 0, 0, WIDTH, HEIGHT ]);
const background = svg.append("rect")
// re transitions: they should only run when updating the palette and
// grid, not via the sliders
// see https://www.d3indepth.com/transitions/ and use enter / exit etc
// note
// do background as a select so that transitions work
const bg_g = svg.append("g")
.attr("id", "background");
bg_g.selectAll("rect")
.data( [ { bg: bg } ] )
.join("rect")
.attr("x", 0) .attr("x", 0)
.attr("y", 0) .attr("y", 0)
.attr("width", WIDTH) .attr("width", WIDTH)
.attr("height", WIDTH) .attr("height", WIDTH)
.attr("fill", bg); .attr("fill", (d) => d.bg)
;
const dots_g1 = svg.append("g") const dots_g1 = svg.append("g")
.attr("id", "dots1"); .attr("id", "dots1");
@ -129,10 +163,9 @@ const dots_g1 = svg.append("g")
dots_g1.selectAll("circle") dots_g1.selectAll("circle")
.data(dots1) .data(dots1)
.join("circle") .join("circle")
.attr("r", (d) => dm.radius(d, f1, r1))
.attr("fill", fg1)
.attr("cx", (d) => d.x) .attr("cx", (d) => d.x)
.attr("cy", (d) => d.y); .attr("cy", (d) => d.y)
.attr("fill", fg1);
const dots_g2 = svg.append("g") const dots_g2 = svg.append("g")
.attr("id", "dots2"); .attr("id", "dots2");
@ -140,19 +173,44 @@ const dots_g2 = svg.append("g")
dots_g2.selectAll("circle") dots_g2.selectAll("circle")
.data(dots2) .data(dots2)
.join("circle") .join("circle")
.attr("r", (d) => dm.radius(d, f2, r2))
.attr("fill", fg2)
.attr("cx", (d) => d.x) .attr("cx", (d) => d.x)
.attr("cy", (d) => d.y); .attr("cy", (d) => d.y)
.attr("fill", fg2);
display(svg.node()); display(svg.node());
```
```js
// separate code block for when I understand transitions better
dots_g1.selectAll("circle").attr("r", (d) => dm.radius(d, f1, r1));
dots_g2.selectAll("circle").attr("r", (d) => dm.radius(d, f2, r2));
``` ```
</div> ```js
display(download(() => {
const thing = download_as_svg(svg.node())
return thing;
}, "poptimal.svg", "Save as SVG"));
```
```js
await wasm_init;
display(download(() => {
console.log("PNG value");
const thing = download_as_png(svg.node())
return thing;
}, "poptimal.png", "Save as PNG"));
```
(PNGs made with <a href="https://github.com/thx/resvg-js">resvg-wasm</a> in-browser)
</div> </div>
<style> <style>