🍱

大量のデータをいい感じに面積で表現するアレをSwiftUIで実装

2023/05/06に公開

はじめに

上の画像をSwiftUIで実装します。上の画像は都道府県の面積を表現しています。

やり方

大きい方からいくつかを選ぶ(ここではそれを塊という)

縦と横の長い方を選ぶ(長い方の端に塊を描くので)

その塊が全体の何%か計算し、塊を描く領域を決定。

その領域内に塊内の大きい方から並べる。

残りに対して同じことをする

ポイント

このアルゴリズムのポイントはひとつ。

塊を選ぶときにいくつ選ぶか

です。私の場合は次のようにしました。

塊の中で一番小さいセル(あるいは最後に選ばれたセル)を出来るだけ正方形にする

処理としては、セルをひとつずつ増やしていって、最後に選ばれたものが(前回の最後のセルと比べて)正方形から遠のいたらその案は却下する、というやり方をします。

どれだけ正方形か比べるところは

let oldRatio = oldW / oldH
let newRatio = newW / newH

let oldRatio2 = max(oldRatio, 1.0 / oldRatio)
let newRatio2 = max(newRatio, 1.0 / newRatio)

のような感じで oldRatio2newRatio2 の大小を比べるやり方です。小さい方が正方形に近い。

ソースコード

まずはレイアウトのために定義された型とレイアウト計算関数

enum Direction {
    case h
    case v
}

class LayoutData {
    var direction: Direction = .h
    var content: [(index: Int, w: Double, h: Double)] = []
    var child: LayoutData? = nil
}

func calculateLayout(w: Double, h: Double, data: [Double], from: Int) -> LayoutData {
    
    let returnData = LayoutData()
    let dataToArea = w * h / data[from...].reduce(0.0, +)
    if w < h {
        returnData.direction = .v //縦に2つ並べる。ひとつ目はいくつかの四角(横に並べる)。二つ目は子。
    } else {
        returnData.direction = .h
    }
    let mainLength = min(w, h) //個々の長方形を並べる方向がmain
    var currentIndex = from //この値を増やしていく
    var area = data[currentIndex] * dataToArea //グループ全体の面積(増やしていく)
    var crossLength = area / mainLength //mainに直交するのがcross
    
    // セルが正方形に近いか調べている
    var cellRatio = mainLength / crossLength
    cellRatio = max(cellRatio, 1.0 / cellRatio)
    
    while currentIndex + 1 < data.count {
        let newIndex = currentIndex + 1
        let newArea = area + data[newIndex] * dataToArea
        let newCrossLength = newArea / mainLength
        var newCellRatio = data[newIndex] * dataToArea / newCrossLength / newCrossLength
        newCellRatio = max(newCellRatio, 1.0 / newCellRatio)
        
        if newCellRatio < cellRatio {
            //付け足した方が正方形に近かった
            currentIndex = newIndex
            area = newArea
            crossLength = newCrossLength
            cellRatio = newCellRatio
        } else {
            break
        }
    }
    
    switch returnData.direction {
        case .h:
            for i in from...currentIndex {
                returnData.content.append((
                    index: i, 
                    w: crossLength, 
                    h: data[i] * dataToArea / crossLength))
            }
        case .v:
            for i in from...currentIndex {
                returnData.content.append((
                    index: i, 
                    w: data[i] * dataToArea / crossLength, 
                    h: crossLength))
            }
    }
    
    if currentIndex != data.count - 1 {
        switch returnData.direction {
            case .h:
                returnData.child = calculateLayout(
                    w: w - crossLength, 
                    h: h, 
                    data: data, 
                    from: currentIndex + 1)
            case .v:
                returnData.child = calculateLayout(
                    w: w, 
                    h: h - crossLength, 
                    data: data, 
                    from: currentIndex + 1)
        }
    }
    return returnData
}

次にView

import SwiftUI

struct ContentView: View {
    var body: some View {
        GeometryReader { proxy in
            let ld = calculateLayout(
                w: proxy.size.width, 
                h: proxy.size.height,
                data: prefectureData.map({ (name: String, area: Double) in
                    area
                }),
                from: 0
            )
            
            TreeMapView(ld: ld)
        }
        .ignoresSafeArea()
    }
}

struct TreeMapView: View {
    
    let ld: LayoutData
    
    var body: some View {
        if ld.direction == .h {
            HStack(spacing: 0.0) {
                VStack(spacing: 0.0) {
                    ForEach(0..<ld.content.count, id: \.self) { i in
                        Rectangle()
                            .foregroundColor(randomColor())
                            .frame(width: ld.content[i].w, 
                                   height: ld.content[i].h)
                            .overlay { 
                                Text(prefectureData[ld.content[i].index].name)
                            }
                    }
                }
                if let child = ld.child {
                    TreeMapView(ld: child)
                }
            }
        } else {
            VStack(spacing: 0.0) {
                HStack(spacing: 0.0) {
                    ForEach(0..<ld.content.count, id: \.self) { i in
                        Rectangle()
                            .foregroundColor(randomColor())
                            .frame(width: ld.content[i].w, 
                                   height: ld.content[i].h)
                            .overlay { 
                                Text(prefectureData[ld.content[i].index].name)
                            }
                    }
                }
                if let child = ld.child {
                    TreeMapView(ld: child)
                }
            }
        }
    }
    
    func randomColor() -> Color {
        Color(hue: Double.random(in: 0.0...1.0), 
              saturation: Double.random(in: 0.08...0.18), 
              brightness: Double.random(in: 0.90...1.0))
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

最後に都道府県データ

let prefectureData: [(name: String, area: Double)] =
[
    (name: "北海道", area: 83457.00),
    (name: "岩手県", area: 15278.89),
    (name: "福島県", area: 13782.76),
    (name: "長野県", area: 13562.23),
    (name: "新潟県", area: 12583.83),
    (name: "秋田県", area: 11636.28),
    (name: "岐阜県", area: 10621.17),
    (name: "青森県", area: 9644.55),
    (name: "山形県", area: 9323.46),
    (name: "鹿児島県", area: 9188.82),
    (name: "広島県", area: 8479.70),
    (name: "兵庫県", area: 8396.16),
    (name: "静岡県", area: 7780.50),
    (name: "宮崎県", area: 7735.99),
    (name: "熊本県", area: 7404.79),
    (name: "宮城県", area: 7285.77),
    (name: "岡山県", area: 7113.23),
    (name: "高知県", area: 7105.16),
    (name: "島根県", area: 6707.96),
    (name: "栃木県", area: 6408.28),
    (name: "群馬県", area: 6362.33),
    (name: "大分県", area: 6339.74),
    (name: "山口県", area: 6114.09),
    (name: "茨城県", area: 6095.72),
    (name: "三重県", area: 5777.31),
    (name: "愛媛県", area: 5678.33),
    (name: "愛知県", area: 5165.12),
    (name: "千葉県", area: 5156.61),
    (name: "福岡県", area: 4978.51),
    (name: "和歌山県", area: 4726.29),
    (name: "京都府", area: 4613.21),
    (name: "山梨県", area: 4465.37),
    (name: "富山県", area: 4247.61),
    (name: "福井県", area: 4189.88),
    (name: "石川県", area: 4185.67),
    (name: "徳島県", area: 4146.74),
    (name: "長崎県", area: 4105.47),
    (name: "滋賀県", area: 4017.36),
    (name: "埼玉県", area: 3798.08),
    (name: "奈良県", area: 3691.09),
    (name: "鳥取県", area: 3507.28),
    (name: "佐賀県", area: 2439.65),
    (name: "神奈川県", area: 2415.85),
    (name: "沖縄県", area: 2276.49),
    (name: "東京都", area: 2188.67),
    (name: "大阪府", area: 1899.28),
    (name: "香川県", area: 1876.55),
]

入れ子によって構造が肥大化するのが気になりますが、そのそも人間が見て認識できる程度のデータの個数であろうということでよしとしました。
何か指摘があったらコメントよろしくお願いします。

調べたらこの手法はツリーマップというらしい。

https://github.com/samekard-dev/tree-map

Discussion