-
Notifications
You must be signed in to change notification settings - Fork 7
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Merge pull request #220 from gnosis/deterministic-avatar
- Loading branch information
Showing
22 changed files
with
564 additions
and
81 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,41 @@ | ||
import { Avatar as MaterialAvatar } from "@mui/material" | ||
import React, { Fragment, useState } from "react" | ||
import { useDynamicFavIcon } from "../../hooks/useDynamicFavIco" | ||
import { usePublicationContext } from "../../services/publications/contexts" | ||
import usePublication from "../../services/publications/hooks/usePublication" | ||
import DeterministicAvatar from "./DeterministicAvatar" | ||
|
||
interface AvatarProps { | ||
publicationSlug: string | ||
height: number | ||
width: number | ||
storeImage?: boolean | ||
dynamicFavIcon?: boolean | ||
} | ||
|
||
const Avatar: React.FC<AvatarProps> = ({ publicationSlug, height, width, storeImage, dynamicFavIcon }) => { | ||
const { imageSrc, data: publication } = usePublication(publicationSlug) | ||
const { setPublicationAvatar } = usePublicationContext() | ||
const [avatar, setAvatar] = useState<string | undefined>(imageSrc) | ||
|
||
useDynamicFavIcon(dynamicFavIcon ? (imageSrc ? imageSrc : avatar) : undefined) | ||
const handleImage = (uri: string) => { | ||
if (storeImage && publication && publication.id) { | ||
setAvatar(uri) | ||
setPublicationAvatar({ publicationId: publication.id, uri }) | ||
} | ||
} | ||
return ( | ||
<Fragment> | ||
{imageSrc ? ( | ||
<MaterialAvatar sx={{ width, height }} src={imageSrc}> | ||
{" "} | ||
</MaterialAvatar> | ||
) : publication ? ( | ||
<DeterministicAvatar hash={publication.hash} width={width} height={height} onImageGenerated={handleImage} /> | ||
) : null} | ||
</Fragment> | ||
) | ||
} | ||
|
||
export default Avatar |
253 changes: 253 additions & 0 deletions
253
packages/app/src/components/commons/DeterministicAvatar/index.tsx
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,253 @@ | ||
import React from "react" | ||
import Sketch from "react-p5" | ||
import p5Types from "p5" | ||
import Random, { genTokenData } from "./utils" | ||
import { Box } from "@mui/material" | ||
interface DeterministicAvatarProps { | ||
hash?: string | ||
width?: number | ||
height?: number | ||
onImageGenerated?: (image: string) => void | ||
} | ||
|
||
const DeterministicAvatar: React.FC<DeterministicAvatarProps> = ({ | ||
hash, | ||
width = 160, | ||
height = 160, | ||
onImageGenerated, | ||
}) => { | ||
let x: number = 10 | ||
let y: number = 10 | ||
let markingsLayers: number = 0 | ||
let noiseOpacity: number = 1 | ||
let noiseImg: any | ||
let layers: any[] = [] | ||
let palette: any[] = [] | ||
const divisions = 20 | ||
let tokenData | ||
if (hash) { | ||
const publicationHash = hash?.replace("P-", "").replace(/(\d)-(\d*)/, "$1") | ||
const publicationtokenId = hash?.match(/(\d)-(\d*)/) | ||
tokenData = { hash: publicationHash, tokenId: publicationtokenId && publicationtokenId[2] } | ||
} else { | ||
tokenData = genTokenData(123) | ||
} | ||
const R: any = new Random(tokenData) | ||
|
||
for (let i = 0; i < divisions; i++) { | ||
palette.push([0, 0, 100]) // white | ||
const newFill = [R.random_num(0, 140), R.random_num(30, 65), R.random_num(70, 90)] | ||
palette.push(newFill) // color | ||
} | ||
|
||
const accentHue = R.random_num(0, 360) | ||
const bgColor = [accentHue, R.random_num(10, 30), R.random_num(90, 100)] | ||
const strokeColor = [accentHue, R.random_num(20, 40), R.random_num(30, 40)] | ||
const outerStrokeColor = [accentHue, R.random_num(50, 80), R.random_num(50, 60)] | ||
|
||
const strokeWeight = 0.012 * width | ||
|
||
function Layer(this: any, size: number, pieces: number, showCircles: boolean, theta: number, fill: any) { | ||
this.size = size | ||
this.pieces = pieces | ||
this.showCircles = showCircles | ||
this.theta = theta | ||
this.fill = fill | ||
} | ||
|
||
function Piece( | ||
this: any, | ||
theta: number, | ||
radius: number, | ||
rotation: number, | ||
fill: any, | ||
strokeWeight: number, | ||
strokeColor: any, | ||
bool_seeds: number[], | ||
p5: p5Types, | ||
innerRadius?: number, | ||
outerRadius?: number, | ||
) { | ||
this.theta = theta | ||
this.initialRadius = radius | ||
this.radius = radius | ||
this.innerRadius = innerRadius | ||
this.outerRadius = radius | ||
this.rotation = rotation | ||
this.initialRotation = rotation | ||
this.fill = fill | ||
this.strokeWeight = strokeWeight | ||
this.strokeColor = strokeColor | ||
this.bool_seeds = bool_seeds | ||
|
||
this.render = function () { | ||
p5.stroke(this.strokeColor) | ||
p5.push() | ||
p5.translate(width / 2, height / 2) | ||
let x1, x2, y1, y2 | ||
p5.rotate(this.rotation) | ||
if (innerRadius) { | ||
const t = this.theta | ||
x1 = this.innerRadius * p5.cos(t - this.theta / 2 + 45 / 2) | ||
y1 = this.innerRadius * p5.sin(t - this.theta / 2 + 45 / 2) | ||
x2 = this.outerRadius * p5.cos(t + 45 / 2) | ||
y2 = this.outerRadius * p5.sin(t + 45 / 2) | ||
let x3 = this.innerRadius * p5.cos(t + this.theta / 2 + 45 / 2) | ||
let y3 = this.innerRadius * p5.sin(t + this.theta / 2 + 45 / 2) | ||
p5.fill(this.fill) | ||
p5.beginShape() | ||
p5.vertex(x1, y1) | ||
p5.vertex(x2, y2) | ||
p5.vertex(x3, y3) | ||
p5.endShape(p5.CLOSE) | ||
} else { | ||
x1 = this.radius | ||
y1 = 0 | ||
x2 = this.radius * p5.cos(this.theta) | ||
y2 = this.radius * p5.sin(this.theta) | ||
|
||
const starPointOffsetRatio = this.bool_seeds[0] ? 0 : 0.1 | ||
p5.fill(this.fill) | ||
p5.beginShape() | ||
p5.vertex(x1, y1) | ||
p5.vertex(0, 0) | ||
p5.vertex(x2, y2) | ||
p5.endShape(p5.CLOSE) | ||
p5.line(x1, y1, x2, y2) | ||
p5.line(x1 - x1 * starPointOffsetRatio, y1 - y1 * starPointOffsetRatio, x2 - x2 * 0.65, y2 - y2 * 0.65) | ||
p5.line(x2 - x2 * starPointOffsetRatio, y2 - y2 * starPointOffsetRatio, x1 - x1 * 0.65, y1 - y1 * 0.65) | ||
} | ||
p5.pop() | ||
} | ||
} | ||
|
||
const setup = (p5: p5Types, canvasParentRef: Element) => { | ||
p5.noLoop() | ||
p5.createCanvas(width, height).parent(canvasParentRef) | ||
p5.colorMode(p5.HSB, 360, 100, 100, 1) | ||
p5.pixelDensity(2) | ||
p5.angleMode(p5.DEGREES) | ||
p5.imageMode(p5.CENTER) | ||
p5.ellipseMode(p5.CENTER) | ||
markingsLayers = p5.round(R.random_num(4, 7)) | ||
p5.strokeJoin(p5.ROUND) | ||
p5.strokeWeight(strokeWeight) | ||
initializeMarkings(p5) | ||
initializeNoiseLayer(p5) | ||
} | ||
|
||
const draw = (p5: p5Types) => { | ||
p5.background(bgColor) | ||
renderMarkings(p5) | ||
renderNoise(p5) | ||
addBorder(p5) | ||
//@ts-ignore | ||
const canvas = p5.canvas.toDataURL("image/png") | ||
if (onImageGenerated) { | ||
onImageGenerated(canvas) | ||
} | ||
} | ||
|
||
const initializeMarkings = (p5: p5Types) => { | ||
const layerWithCircles = R.shuffleArray(Array.from(Array(markingsLayers).keys())).slice(0, 2) | ||
p5.stroke(43, 2, 20) | ||
for (let i = 0; i < markingsLayers; i++) { | ||
const pieces = [] | ||
const fillValue = palette[i] | ||
const bool_seeds = [R.random_bool(0.5), R.random_bool(0.93)] | ||
const radius = p5.map(i, 0, markingsLayers, width * 0.43, height * 0.05) | ||
const isRay = R.random_bool(0.65) | ||
const piecesNum = R.random_choice([!isRay ? 4 : 0, 8, 16]) | ||
const rotOffset = isRay && piecesNum === 4 ? 22.5 : 0 | ||
const innerRadius = isRay ? radius * R.random_num(0.5, 0.8) : null | ||
const theta = 360 / piecesNum | ||
for (let p = 0; p < piecesNum; p++) { | ||
pieces.push( | ||
new (Piece as any)( | ||
theta, // theta, | ||
radius, // radius, | ||
theta * p + rotOffset, // rotation, | ||
fillValue, // fill | ||
strokeWeight, // strokeWeight | ||
strokeColor, // strokeColor | ||
bool_seeds, // bool_seeds | ||
p5, // p5 | ||
innerRadius, // innerRadius | ||
), | ||
) | ||
} | ||
layers.push( | ||
new (Layer as any)( | ||
p5.map(i, 0, markingsLayers, width * 0.43, height * 0.05), // size | ||
pieces, // pieces | ||
layerWithCircles.some((layer: number) => layer === i), // showCircles | ||
theta, // theta | ||
fillValue, // fill | ||
), | ||
) | ||
} | ||
} | ||
|
||
const initializeNoiseLayer = (p5: p5Types) => { | ||
noiseOpacity = R.random_num(0.1, 0.3) | ||
noiseImg = p5.createGraphics(width, height) | ||
|
||
for (x = 0; x <= width; x++) { | ||
for (y = 0; y <= height; y++) { | ||
noiseImg.noStroke() | ||
noiseImg.fill(p5.map(R.random_dec(), 0, 1, 0, 255)) | ||
noiseImg.circle(x, y, 1) | ||
} | ||
} | ||
} | ||
|
||
const renderMarkings = (p5: p5Types) => { | ||
layers.forEach((layer: any) => { | ||
layer.pieces.forEach((piece: any) => { | ||
piece.render() | ||
}) | ||
if (layer.showCircles) { | ||
const circleSize = R.random_num(0.2, 0.4) | ||
layer.pieces.forEach((piece: any) => { | ||
p5.push() | ||
p5.translate(width / 2, height / 2) | ||
const rotOffset = 22.5 | ||
p5.rotate(piece.rotation + rotOffset) | ||
const cx = layer.size * p5.cos(layer.theta + 45 / 2) | ||
const cy = layer.size * p5.sin(layer.theta + 45 / 2) | ||
const d = 2 * p5.abs(p5.sin(layer.theta / 2)) * layer.size | ||
p5.strokeWeight(strokeWeight) | ||
p5.stroke(strokeColor) | ||
p5.fill(layer.fill) | ||
p5.circle(cx, cy, d * circleSize) | ||
p5.pop() | ||
}) | ||
} | ||
}) | ||
} | ||
|
||
const renderNoise = (p5: p5Types) => { | ||
if (noiseImg) { | ||
p5.translate(p5.width / 2, p5.height / 2) | ||
p5.blendMode(p5.HARD_LIGHT) | ||
p5.tint(255, noiseOpacity) | ||
p5.image(noiseImg, 0, 0, p5.width, p5.height) | ||
p5.blendMode(p5.BLEND) | ||
} | ||
} | ||
|
||
const addBorder = (p5: p5Types) => { | ||
p5.noFill() | ||
p5.stroke(outerStrokeColor) | ||
p5.strokeWeight(strokeWeight * 2) // half of the border gets cropped. | ||
p5.ellipse(0, 0, width - strokeWeight, height - strokeWeight) | ||
} | ||
|
||
return ( | ||
<Box sx={{ borderRadius: 999, overflow: "hidden", width: width, height: height }}> | ||
<Sketch setup={setup} draw={draw} /> | ||
</Box> | ||
) | ||
} | ||
|
||
export default DeterministicAvatar |
82 changes: 82 additions & 0 deletions
82
packages/app/src/components/commons/DeterministicAvatar/utils.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,82 @@ | ||
class Random { | ||
constructor(tokenData) { | ||
this.tokenData = tokenData | ||
this.useA = false | ||
let sfc32 = function (uint128Hex) { | ||
let a = parseInt(uint128Hex.substr(0, 8), 16) | ||
let b = parseInt(uint128Hex.substr(8, 8), 16) | ||
let c = parseInt(uint128Hex.substr(16, 8), 16) | ||
let d = parseInt(uint128Hex.substr(24, 8), 16) | ||
return function () { | ||
a |= 0 | ||
b |= 0 | ||
c |= 0 | ||
d |= 0 | ||
let t = (((a + b) | 0) + d) | 0 | ||
d = (d + 1) | 0 | ||
a = b ^ (b >>> 9) | ||
b = (c + (c << 3)) | 0 | ||
c = (c << 21) | (c >>> 11) | ||
c = (c + t) | 0 | ||
return (t >>> 0) / 4294967296 | ||
} | ||
} | ||
// seed prngA with first half of tokenData.hash | ||
this.prngA = new sfc32(this.tokenData.hash.substr(2, 32)) | ||
// seed prngB with second half of tokenData.hash | ||
this.prngB = new sfc32(this.tokenData.hash.substr(34, 32)) | ||
for (let i = 0; i < 1e6; i += 2) { | ||
this.prngA() | ||
this.prngB() | ||
} | ||
} | ||
// random number between 0 (inclusive) and 1 (exclusive) | ||
random_dec() { | ||
this.useA = !this.useA | ||
return this.useA ? this.prngA() : this.prngB() | ||
} | ||
// random number between a (inclusive) and b (exclusive) | ||
random_num(a, b) { | ||
return a + (b - a) * this.random_dec() | ||
} | ||
// random integer between a (inclusive) and b (inclusive) | ||
// requires a < b for proper probability distribution | ||
random_int(a, b) { | ||
return Math.floor(this.random_num(a, b + 1)) | ||
} | ||
// random boolean with p as percent liklihood of true | ||
random_bool(p) { | ||
return this.random_dec() < p | ||
} | ||
// random value in an array of items | ||
random_choice(list) { | ||
return list[this.random_int(0, list.length - 1)] | ||
} | ||
|
||
shuffleArray = (arr) => { | ||
var rand | ||
var tmp | ||
var len = arr.length | ||
var ret = [...arr] | ||
while (len) { | ||
rand = ~~(this.random_dec() * len--) | ||
tmp = ret[len] | ||
ret[len] = ret[rand] | ||
ret[rand] = tmp | ||
} | ||
return ret | ||
} | ||
} | ||
|
||
export const genTokenData = (projectNum) => { | ||
let data = {} | ||
let hash = "0x" | ||
for (var i = 0; i < 64; i++) { | ||
hash += Math.floor(Math.random() * 16).toString(16) | ||
} | ||
data.hash = hash | ||
data.tokenId = (projectNum * 1000000 + Math.floor(Math.random() * 1000)).toString() | ||
return data | ||
} | ||
|
||
export default Random |
Oops, something went wrong.
aee23e2
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Successfully deployed to the following URLs:
tabula – ./
tabula-eight.vercel.app
tabula-gnosis-guild.vercel.app
tabula-git-main-gnosis-guild.vercel.app