Skip to content

Commit

Permalink
feat: iOS dot identicon spm (#1979)
Browse files Browse the repository at this point in the history
* feat: ios: setup PolkadotIdenticon package

* feat: ios: migrating part of Circle code

* feat: ios: public key formats conversion

* feat: ios: public key decoding logic

* feat: ios: clean up, all necessary models, layout generation

* feat: ios: Blake2 based colors generator

* Update ios/Packages/PolkadotIdenticon/README.md

Co-authored-by: Pavel Rybalko <[email protected]>

* feat: ios: generate scalable dot identicons that reflect by-pixel rendering of Rust library

* feat: ios: Final icon rendering as UIImage

* feat: ios: SwiftUI wrapper and test previews

* feat: ios: update SPM

---------

Co-authored-by: Pavel Rybalko <[email protected]>
  • Loading branch information
krodak and prybalko authored Aug 2, 2023
1 parent 75d213b commit 56e9c21
Show file tree
Hide file tree
Showing 17 changed files with 922 additions and 4 deletions.
9 changes: 9 additions & 0 deletions ios/Packages/PolkadotIdenticon/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
.DS_Store
/.build
/Packages
/*.xcodeproj
xcuserdata/
DerivedData/
.swiftpm/config/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
33 changes: 33 additions & 0 deletions ios/Packages/PolkadotIdenticon/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
// swift-tools-version: 5.7

import PackageDescription

let package = Package(
name: "PolkadotIdenticon",
platforms: [
.iOS(.v15)
],
products: [
.library(
name: "PolkadotIdenticon",
targets: ["PolkadotIdenticon"]
)
],
dependencies: [
.package(url: "https://github.com/tesseract-one/Blake2.swift.git", from: "0.1.0"),
.package(url: "https://github.com/attaswift/BigInt.git", from: "5.3.0")
],
targets: [
.target(
name: "PolkadotIdenticon",
dependencies: [
.product(name: "Blake2", package: "Blake2.swift"),
.product(name: "BigInt", package: "BigInt")
]
),
.testTarget(
name: "PolkadotIdenticonTests",
dependencies: ["PolkadotIdenticon"]
)
]
)
3 changes: 3 additions & 0 deletions ios/Packages/PolkadotIdenticon/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# Polkadot Identicon

A description of this package.
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
//
// CircleLayoutGenerator.swift
//
//
// Created by Krzysztof Rodak on 24/07/2023.
//

import Foundation

public final class CircleLayoutGenerator {
public init() {}
/// Returns an array of circles with assigned positions, radii and colors.
/// The circles are positioned in a radial layout and each is assigned a color from the provided array.
/// - Parameters:
/// - distanceBetweenCenters: The distance between the centers of adjacent circles.
/// - circleRadius: The radius of each circle.
/// - colors: An array of colors to be assigned to the circles. The colors are assigned in the order they appear
/// in the array.
/// - Returns: An array of circles with assigned positions, radii and colors.
func generateCircles(distanceBetweenCenters: Float, circleRadius: Float, colors: [Color]) -> [Circle] {
calculateCirclePositions(distanceBetweenCenters: distanceBetweenCenters)
.enumerated()
.map { index, position in Circle(
position: .init(centerX: position.centerX, centerY: position.centerY),
radius: circleRadius,
rgba_color: colors[index]
) }
}
}

private extension CircleLayoutGenerator {
/// Calculates the positions for the centers of the circles in a radial layout.
///
/// The layout is as follows:
///
/// 0
///
/// 2 17
///
/// 3 1 15
///
/// 4 16
///
/// 5 18 14
///
/// 7 13
///
/// 6 10 12
///
/// 8 11
///
/// 9
///
/// - Parameter distanceBetweenCenters: The distance between the centers of adjacent circles.
/// - Returns: An array of positions for the circle centers.
func calculateCirclePositions(distanceBetweenCenters: Float) -> [CirclePosition] {
let a = distanceBetweenCenters
let b = distanceBetweenCenters * sqrt(3) / 2
return [
CirclePosition(centerX: 0, centerY: -2 * a),
CirclePosition(centerX: 0, centerY: -a),
CirclePosition(centerX: -b, centerY: -3 * a / 2),
CirclePosition(centerX: -2 * b, centerY: -a),
CirclePosition(centerX: -b, centerY: -a / 2),
CirclePosition(centerX: -2 * b, centerY: 0),
CirclePosition(centerX: -2 * b, centerY: a),
CirclePosition(centerX: -b, centerY: a / 2),
CirclePosition(centerX: -b, centerY: 3 * a / 2),
CirclePosition(centerX: 0, centerY: 2 * a),
CirclePosition(centerX: 0, centerY: a),
CirclePosition(centerX: b, centerY: 3 * a / 2),
CirclePosition(centerX: 2 * b, centerY: a),
CirclePosition(centerX: b, centerY: a / 2),
CirclePosition(centerX: 2 * b, centerY: 0),
CirclePosition(centerX: 2 * b, centerY: -a),
CirclePosition(centerX: b, centerY: -a / 2),
CirclePosition(centerX: b, centerY: -3 * a / 2),
CirclePosition(centerX: 0, centerY: 0)
]
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,194 @@
//
// IdenticonColorsGenerator.swift
//
//
// Created by Krzysztof Rodak on 31/07/2023.
//

import Blake2
import Foundation

public final class IdenticonColorsGenerator {
private enum Constants {
static let byteHashLength = 64
static let arrayZeroBytesLength = 32
static let derivedIDRotationFactorMultiplier: UInt8 = 6
static let derivedIDRotationFactorModulo: UInt8 = 3
static let hueDegrees = 360
static let colorArrayLength = 19
static let lightnessPercentages = [53, 15, 35, 75]
}

public init() {}

/// Returns an array of 19 colors based on the input data.
///
/// The function first hashes the input data and a zero-filled array with the Blake2b hashing algorithm.
/// It then derives a unique ID by subtracting the hashed zero-filled array from the hashed input data.
/// The ID is used to calculate a saturation component and derive a palette of 19 colors.
/// Finally, it selects a color scheme based on the derived ID and rotates the colors in the scheme.
///
/// - Parameter inputData: A byte array to derive the colors from.
/// - Returns: An array of 19 derived colors.
func deriveColors(from inputData: [UInt8]) -> [Color] {
let zeroBytes = Array(repeating: UInt8(0), count: Constants.arrayZeroBytesLength)

guard let hashedInput = try? Blake2.hash(.b2b, size: Constants.byteHashLength, bytes: inputData),
let hashedZeroBytes = try? Blake2.hash(.b2b, size: Constants.byteHashLength, bytes: zeroBytes) else {
return []
}

// Create an ID array by subtracting elements of hashedInput and zeroBytes.
var derivedID: [UInt8] = []
for (index, byte) in hashedInput.enumerated() {
let newValue = byte &- hashedZeroBytes[index]
derivedID.append(newValue)
}

let colorPalette = deriveColors(derivedID: derivedID)

// Choose the color scheme based on the 30th and 31st byte of the derived ID.
let colorSchemes = ColorScheme.defaultColorSchemes
let totalFrequency: Int = colorSchemes.reduce(0) { $0 + $1.frequency }
let selectionFactor = (UInt(derivedID[30]) + UInt(derivedID[31]) * 256) % UInt(totalFrequency)
let selectedScheme = chooseScheme(schemes: colorSchemes, selectionFactor: selectionFactor)

// Calculate rotation factor for the color scheme.
let rotationFactor = (derivedID[28] % Constants.derivedIDRotationFactorMultiplier) * Constants
.derivedIDRotationFactorModulo

// Generate the final array of colors using selected color scheme and rotation factor.
return (0 ..< Constants.colorArrayLength).map { index in
let colorIndex = index < Constants
.colorArrayLength - 1 ? (index + Int(rotationFactor)) % (Constants.colorArrayLength - 1) : Constants
.colorArrayLength - 1
let paletteIndex = selectedScheme.colorPaletteIndices[colorIndex]
return colorPalette[paletteIndex]
}
}

private func deriveColors(derivedID: [UInt8]) -> [Color] {
// Calculate saturation component using 29th byte of the derived ID.
let sat = UInt8((((UInt(derivedID[29]) * 70) / 256 + 26) % 80) + 30)
let saturationComponent: Double = Double(sat) / 100.0

// Generate palette of colors using derived ID and saturation component.
return derivedID
.enumerated()
.map { index, byte in
let byteColor = byte &+ (UInt8(index % 28) &* 58)
switch byteColor {
case 0:
return Color(red: 4, green: 4, blue: 4, alpha: 255)
case 255:
return Color.foregroundColor
default:
return derive(fromByte: byteColor, saturationComponent: saturationComponent)
}
}
}

/// Derives a color from a byte and a saturation component.
///
/// - Parameters:
/// - byte: The byte to derive the color from.
/// - saturationComponent: The saturation component to use.
/// - Returns: The derived color.
private func derive(fromByte byte: UInt8, saturationComponent: Double) -> Color {
// HSL color hue in degrees
let hueModulus = 64
let hue = Int(byte % UInt8(hueModulus)) * Constants.hueDegrees / hueModulus
// HSL lightness in percents
let lightnessIndexFactor: UInt8 = 64
let lightnessIndex = byte / lightnessIndexFactor
let lightnessPercentage = lightnessIndex < Constants.lightnessPercentages.count ? Constants
.lightnessPercentages[Int(lightnessIndex)] : 0
let lightnessComponent: Double = Double(lightnessPercentage) / 100.0
let (red, green, blue) = hslToRgb(
hue: Double(hue),
saturation: saturationComponent,
lightness: lightnessComponent
)

return Color(red: red, green: green, blue: blue, alpha: 255)
}

/// Choose a color scheme based on a selection factor.
///
/// - Parameters:
/// - schemes: An array of color schemes.
/// - selectionFactor: A selection factor to determine which color scheme to use.
/// - Returns: The selected color scheme.
private func chooseScheme(schemes: [ColorScheme], selectionFactor: UInt) -> ColorScheme {
var sum: UInt = 0
var foundScheme: ColorScheme?
for scheme in schemes {
sum += UInt(scheme.frequency)
if selectionFactor < sum {
foundScheme = scheme
break
}
}
return foundScheme!
}

/// Converts HSL color space values to RGB color space values.
///
/// - Parameters:
/// - hue: The hue value of the HSL color, specified as a degree between 0 and 360.
/// - saturation: The saturation value of the HSL color, specified as a double between 0 and 1.
/// - lightness: The lightness value of the HSL color, specified as a double between 0 and 1.
/// - Returns: A tuple representing the RGB color values, each a UInt8 between 0 and 255.
private func hslToRgb(
hue: Double,
saturation: Double,
lightness: Double
) -> (red: UInt8, green: UInt8, blue: UInt8) {
var redComponent: Double = 0.0
var greenComponent: Double = 0.0
var blueComponent: Double = 0.0

let normalizedHue = hue / 360.0

if saturation == 0.0 {
// Achromatic color (gray scale)
redComponent = lightness
greenComponent = lightness
blueComponent = lightness
} else {
let qValue = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness *
saturation
let pValue = 2 * lightness - qValue

redComponent = convertHueToRgbComponent(p: pValue, q: qValue, hueShift: normalizedHue + 1 / 3)
greenComponent = convertHueToRgbComponent(p: pValue, q: qValue, hueShift: normalizedHue)
blueComponent = convertHueToRgbComponent(p: pValue, q: qValue, hueShift: normalizedHue - 1 / 3)
}

return (
red: UInt8(max(min(floor(redComponent * 256), 255), 0)),
green: UInt8(max(min(floor(greenComponent * 256), 255), 0)),
blue: UInt8(max(min(floor(blueComponent * 256), 255), 0))
)
}

/// Calculates a single RGB color component from HSL values.
///
/// - Parameters:
/// - p: The first helper value derived from the lightness value of the HSL color.
/// - q: The second helper value derived from the lightness and saturation values of the HSL color.
/// - hueShift: The hue value of the HSL color, shifted by a certain amount.
/// - Returns: A double representing the calculated RGB color component.
private func convertHueToRgbComponent(p: Double, q: Double, hueShift: Double) -> Double {
var shiftedHue = hueShift

if shiftedHue < 0 { shiftedHue += 1 }
if shiftedHue > 1 { shiftedHue -= 1 }

if shiftedHue < 1 / 6 { return p + (q - p) * 6 * shiftedHue }
if shiftedHue < 1 / 2 { return q }
if shiftedHue < 2 / 3 { return p + (q - p) * (2 / 3 - shiftedHue) * 6 }

return p
}
}
Loading

0 comments on commit 56e9c21

Please sign in to comment.