@@ -20,7 +20,9 @@ import {
2020 Droplets ,
2121 Info ,
2222 Dices ,
23+ Package ,
2324} from "lucide-react" ;
25+ import JSZip from "jszip" ;
2426
2527/**
2628 * Seeded SVG Splotch Generator
@@ -515,10 +517,13 @@ function simulate(params: Params) {
515517 // inside the huge workingW buffer.
516518 const physW = displayW ;
517519
518- // For spray geometry, offset the center opposite to spray direction
520+ // For spray and line geometry, offset the center opposite to spray direction
519521 // to compensate for the drift caused by spray magnitude
520522 let center = { x : workingW / 2 , y : workingW / 2 } ;
521- if ( params . geometry === "spray" && params . sprayMagnitude > 0 ) {
523+ if (
524+ ( params . geometry === "spray" || params . geometry === "line" ) &&
525+ params . sprayMagnitude > 0
526+ ) {
522527 const ang = ( params . sprayAngleDeg * Math . PI ) / 180 ;
523528 // Offset opposite to spray direction, scaled by magnitude and physics size
524529 const offsetAmount = params . sprayMagnitude * physW * 0.35 ;
@@ -542,13 +547,21 @@ function simulate(params: Params) {
542547 }
543548
544549 if ( params . geometry === "line" ) {
550+ const ang = ( params . sprayAngleDeg * Math . PI ) / 180 ;
551+ const dir = { x : Math . cos ( ang ) , y : Math . sin ( ang ) } ;
552+ const perp = { x : - dir . y , y : dir . x } ;
553+
545554 const t = rng . range ( - 0.5 , 0.5 ) ;
546- const lineLen = physW * 0.22 ;
547- const p = add ( center , {
548- x : t * lineLen ,
549- y : rng . normal ( ) * ( physW * 0.02 ) ,
550- } ) ;
551- const v = { x : speed * rng . range ( 0.6 , 1.3 ) , y : rng . normal ( ) * 0.2 } ;
555+ // Magnitude controls line length (0 to 1.6 maps to 0 to ~0.35 of physW)
556+ const lineLen = physW * ( 0.05 + params . sprayMagnitude * 0.19 ) ;
557+ const alongOffset = t * lineLen ;
558+ const perpOffset = rng . normal ( ) * ( physW * 0.02 ) ;
559+ const p = add ( center , add ( mul ( dir , alongOffset ) , mul ( perp , perpOffset ) ) ) ;
560+
561+ // Add directional velocity bias based on magnitude
562+ const drift = params . sprayMagnitude * 1.8 ;
563+ const baseSpeed = speed * rng . range ( 0.6 , 1.3 ) ;
564+ const v = add ( mul ( dir , drift + baseSpeed ) , mul ( perp , rng . normal ( ) * 0.2 ) ) ;
552565 return { p, v, vz } ;
553566 }
554567
@@ -998,7 +1011,7 @@ const DEFAULT: Params = {
9981011 sprayMagnitude : 0.0 ,
9991012 sprayCovariance : 0.0 ,
10001013
1001- strokes : 4 ,
1014+ strokes : 1 ,
10021015 flingPower : 22 ,
10031016 directionality : 0.78 ,
10041017 anisotropy : 4.6 ,
@@ -1231,7 +1244,16 @@ if (typeof window !== "undefined") {
12311244}
12321245
12331246export default function App ( ) {
1234- const [ p , setP ] = useState < Params > ( DEFAULT ) ;
1247+ const [ p , setP ] = useState < Params > ( ( ) => {
1248+ // Set magnitude to 1 for line geometry by default
1249+ const initial = { ...DEFAULT } ;
1250+ if ( initial . geometry === "line" ) {
1251+ initial . sprayMagnitude = 1.0 ;
1252+ }
1253+ return initial ;
1254+ } ) ;
1255+ const [ variations , setVariations ] = useState < number > ( 1 ) ;
1256+ const [ isGeneratingVariations , setIsGeneratingVariations ] = useState ( false ) ;
12351257 const dp = useDebounced ( p , 250 ) ;
12361258
12371259 // Check if we're waiting for debounce (params changed but not yet processed)
@@ -1305,6 +1327,126 @@ export default function App() {
13051327 return `splotch_${ safe } _${ p . geometry } .svg` ;
13061328 } , [ p . seed , p . geometry ] ) ;
13071329
1330+ // Helper function to generate SVG text for a given seed offset
1331+ const generateSvgForSeed = ( seedOffset : number ) : string => {
1332+ const modifiedParams : Params = {
1333+ ...p ,
1334+ seed : `${ p . seed } -${ seedOffset } ` ,
1335+ } ;
1336+
1337+ try {
1338+ const simResult = simulate ( modifiedParams ) ;
1339+ const size = p . svgSize ;
1340+ const d = simResult . d ;
1341+
1342+ const meta = {
1343+ seed : modifiedParams . seed ,
1344+ geometry : modifiedParams . geometry ,
1345+ packets : modifiedParams . packets ,
1346+ fieldSize : modifiedParams . fieldSize ,
1347+ threshold : modifiedParams . threshold ,
1348+ smooth : modifiedParams . smooth ,
1349+ blur : modifiedParams . blur ,
1350+ viscosity : modifiedParams . viscosity ,
1351+ restitution : modifiedParams . restitution ,
1352+ drag : modifiedParams . drag ,
1353+ impactSpread : modifiedParams . impactSpread ,
1354+ smear : modifiedParams . smear ,
1355+ noise : modifiedParams . noise ,
1356+ baseRadius : modifiedParams . baseRadius ,
1357+ radiusJitter : modifiedParams . radiusJitter ,
1358+ sprayAngleDeg : modifiedParams . sprayAngleDeg ,
1359+ sprayMagnitude : modifiedParams . sprayMagnitude ,
1360+ sprayCovariance : modifiedParams . sprayCovariance ,
1361+ strokes : modifiedParams . strokes ,
1362+ flingPower : modifiedParams . flingPower ,
1363+ directionality : modifiedParams . directionality ,
1364+ anisotropy : modifiedParams . anisotropy ,
1365+ tail : modifiedParams . tail ,
1366+ tailDroplets : modifiedParams . tailDroplets ,
1367+ panX : modifiedParams . panX ,
1368+ panY : modifiedParams . panY ,
1369+ userScale : modifiedParams . userScale ,
1370+ } ;
1371+
1372+ const desc = `Generated by Seeded SVG Splotch Generator. Params: ${ JSON . stringify ( meta ) } ` ;
1373+
1374+ const flip = p . invertY
1375+ ? ` transform="translate(0 ${ size } ) scale(1 -1)"`
1376+ : "" ;
1377+
1378+ return (
1379+ `<?xml version="1.0" encoding="UTF-8"?>\n` +
1380+ `<svg xmlns="http://www.w3.org/2000/svg" width="${ size } " height="${ size } " viewBox="0 0 ${ size } ${ size } ">\n` +
1381+ ` <desc>${ desc . replace ( / < / g, "<" ) . replace ( / > / g, ">" ) } </desc>\n` +
1382+ ` <g${ flip } >\n` +
1383+ ` <path d="${ d } " fill="#000" fill-rule="evenodd"/>\n` +
1384+ ` </g>\n` +
1385+ `</svg>\n`
1386+ ) ;
1387+ } catch {
1388+ return "" ;
1389+ }
1390+ } ;
1391+
1392+ // Generate filename for a variation
1393+ const getVariationFilename = ( index : number ) : string => {
1394+ const safe =
1395+ p . seed
1396+ . trim ( )
1397+ . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] + / g, "-" )
1398+ . slice ( 0 , 40 ) || "splotch" ;
1399+ return `splotch_${ safe } _${ p . geometry } _v${ index + 1 } .svg` ;
1400+ } ;
1401+
1402+ // Generate and download variations as zip
1403+ const handleDownloadVariations = async ( ) => {
1404+ if ( variations < 1 || variations > 100 ) {
1405+ alert ( "Please enter a number between 1 and 100" ) ;
1406+ return ;
1407+ }
1408+
1409+ setIsGeneratingVariations ( true ) ;
1410+
1411+ try {
1412+ const zip = new JSZip ( ) ;
1413+
1414+ // Generate all variations (offset seed by 1, 2, 3, etc.)
1415+ for ( let i = 0 ; i < variations ; i ++ ) {
1416+ const svgText = generateSvgForSeed ( i + 1 ) ;
1417+ if ( svgText ) {
1418+ const filename = getVariationFilename ( i ) ;
1419+ zip . file ( filename , svgText ) ;
1420+ }
1421+ }
1422+
1423+ // Generate zip file
1424+ const zipBlob = await zip . generateAsync ( { type : "blob" } ) ;
1425+
1426+ // Download zip
1427+ const safe =
1428+ p . seed
1429+ . trim ( )
1430+ . replace ( / [ ^ a - z A - Z 0 - 9 _ - ] + / g, "-" )
1431+ . slice ( 0 , 40 ) || "splotch" ;
1432+ const zipFilename = `splotch_${ safe } _${ p . geometry } _variations.zip` ;
1433+
1434+ const url = URL . createObjectURL ( zipBlob ) ;
1435+ const a = document . createElement ( "a" ) ;
1436+ a . href = url ;
1437+ a . download = zipFilename ;
1438+ document . body . appendChild ( a ) ;
1439+ a . click ( ) ;
1440+ a . remove ( ) ;
1441+ URL . revokeObjectURL ( url ) ;
1442+ } catch ( error ) {
1443+ console . error ( "Error generating variations:" , error ) ;
1444+ alert ( "Error generating variations. Please try again." ) ;
1445+ } finally {
1446+ setIsGeneratingVariations ( false ) ;
1447+ }
1448+ } ;
1449+
13081450 return (
13091451 < div className = "min-h-screen bg-gradient-to-b from-zinc-50 to-white" >
13101452 < div className = "max-w-6xl mx-auto px-4 py-6" >
@@ -1404,11 +1546,16 @@ export default function App() {
14041546 geometry : g ,
14051547 packets : Math . min ( Math . max ( p . packets , 800 ) , 1200 ) ,
14061548 fieldSize : Math . min ( p . fieldSize , 220 ) ,
1407- strokes : Math . min ( Math . max ( p . strokes , 3 ) , 6 ) ,
1549+ strokes : 1 ,
14081550 } ) ;
14091551 return ;
14101552 }
1411- setP ( { ...p , geometry : g } ) ;
1553+ setP ( {
1554+ ...p ,
1555+ geometry : g ,
1556+ // Set magnitude to 1 for line geometry by default
1557+ sprayMagnitude : g === "line" ? 1.0 : p . sprayMagnitude ,
1558+ } ) ;
14121559 } }
14131560 >
14141561 < SelectTrigger className = "rounded-xl" >
@@ -1464,11 +1611,11 @@ export default function App() {
14641611
14651612 < div className = "pt-2 border-t" />
14661613
1467- { p . geometry === "spray" && (
1614+ { ( p . geometry === "spray" || p . geometry === "line" ) && (
14681615 < >
14691616 < Row
1470- label = { `Spray direction (${ Math . round ( p . sprayAngleDeg ) } °)` }
1471- help = "Direction of the spray bias (adds a dominant direction without expensive fling simulation)."
1617+ label = { `Direction (${ Math . round ( p . sprayAngleDeg ) } °)` }
1618+ help = "Direction of the bias (adds a dominant direction without expensive fling simulation)."
14721619 >
14731620 < Slider
14741621 value = { [ p . sprayAngleDeg ] }
@@ -1482,8 +1629,12 @@ export default function App() {
14821629 </ Row >
14831630
14841631 < Row
1485- label = { `Spray magnitude (${ p . sprayMagnitude . toFixed ( 2 ) } )` }
1486- help = "Strength of the directional drift and mean shift. Higher = more fling-like throw."
1632+ label = { `Magnitude (${ p . sprayMagnitude . toFixed ( 2 ) } )` }
1633+ help = {
1634+ p . geometry === "line"
1635+ ? "Controls the length of the line. Higher = longer line."
1636+ : "Strength of the directional drift and mean shift. Higher = more fling-like throw."
1637+ }
14871638 >
14881639 < Slider
14891640 value = { [ p . sprayMagnitude ] }
@@ -1495,7 +1646,11 @@ export default function App() {
14951646 }
14961647 />
14971648 </ Row >
1649+ </ >
1650+ ) }
14981651
1652+ { p . geometry === "spray" && (
1653+ < >
14991654 < Row
15001655 label = { `Spray covariance (${ p . sprayCovariance . toFixed ( 2 ) } )` }
15011656 help = "Anisotropy of the spray cloud. Higher = stretched distribution along the spray direction."
@@ -1702,9 +1857,10 @@ export default function App() {
17021857 Reset all
17031858 </ Button >
17041859 < div className = "text-xs text-muted-foreground leading-relaxed" >
1705- Tips: If you get "no contour", lower < b > Threshold</ b > or
1706- increase < b > Packets</ b > /< b > Base radius</ b > . Higher{ " " }
1707- < b > Field res</ b > yields more detail.
1860+ Tips: If you get "no contour", lower{ " " }
1861+ < b > Threshold</ b > or increase < b > Packets</ b > /
1862+ < b > Base radius</ b > . Higher < b > Field res</ b > yields more
1863+ detail.
17081864 </ div >
17091865 </ div >
17101866 </ CardContent >
@@ -1767,8 +1923,8 @@ export default function App() {
17671923 </ Row >
17681924
17691925 < div className = "text-xs text-muted-foreground" >
1770- Manual placement is always on. It's OK if the blob goes out of
1771- frame — adjust Pan/Scale to compose it.
1926+ Manual placement is always on. It's OK if the blob goes
1927+ out of frame — adjust Pan/Scale to compose it.
17721928 </ div >
17731929 </ CardContent >
17741930 </ Card >
@@ -1876,6 +2032,43 @@ export default function App() {
18762032 </ pre >
18772033 </ details >
18782034
2035+ < div className = "flex items-center gap-3 pt-2 border-t" >
2036+ < div className = "flex-1" >
2037+ < Label htmlFor = "variations" className = "text-xs" >
2038+ Number of variations
2039+ </ Label >
2040+ < Input
2041+ id = "variations"
2042+ type = "number"
2043+ min = "1"
2044+ max = "100"
2045+ value = { variations }
2046+ onChange = { ( e ) => {
2047+ const val = parseInt ( e . target . value , 10 ) ;
2048+ if ( ! isNaN ( val ) && val >= 1 && val <= 100 ) {
2049+ setVariations ( val ) ;
2050+ }
2051+ } }
2052+ className = "mt-1"
2053+ disabled = { isGeneratingVariations }
2054+ />
2055+ </ div >
2056+ < div className = "flex items-end" >
2057+ < Button
2058+ onClick = { handleDownloadVariations }
2059+ className = "rounded-2xl"
2060+ disabled = {
2061+ ! result . d || isGeneratingVariations || variations < 1
2062+ }
2063+ >
2064+ < Package className = "h-4 w-4 mr-2" />
2065+ { isGeneratingVariations
2066+ ? "Generating..."
2067+ : "Download ZIP" }
2068+ </ Button >
2069+ </ div >
2070+ </ div >
2071+
18792072 < div className = "text-xs text-muted-foreground" >
18802073 The export is a single < code > <path></ code > using{ " " }
18812074 < code > fill-rule="evenodd"</ code > .
@@ -1897,6 +2090,20 @@ export default function App() {
18972090 Changing any slider changes output deterministically.
18982091 </ motion . div >
18992092 </ AnimatePresence >
2093+
2094+ < footer className = "mt-12 pt-6 border-t text-center text-xs text-muted-foreground space-y-1" >
2095+ < div > © 2026 Mind the Math LLC</ div >
2096+ < div >
2097+ < a
2098+ href = "https://github.com/mindthemath/splotch"
2099+ target = "_blank"
2100+ rel = "noopener noreferrer"
2101+ className = "hover:text-foreground transition-colors underline"
2102+ >
2103+ github.com/mindthemath/splotch
2104+ </ a >
2105+ </ div >
2106+ </ footer >
19002107 </ div >
19012108 </ div >
19022109 ) ;
0 commit comments