Ecoer Logo
femdev

@femdev

25

Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.

hive.blog/@femdev
VOTING POWER100.00%
DOWNVOTE POWER100.00%
RESOURCE CREDITS100.00%
REPUTATION PROGRESS0.00%
Net Worth
215.000USD
HIVE
30.953HIVE
HBD
0.000HBD
Effective Power
538.052HP
├── Own HP
506.546HP
└── Incoming Deleg
+31.506HP

Detailed Balance

HIVE
balance
30.953HIVE
market_balance
0.000HIVE
savings_balance
0.000HIVE
reward_hive_balance
0.000HIVE
HIVE POWER
Own HP
506.546HP
Delegated Out
0.000HP
Delegation In
31.506HP
Effective Power
538.052HP
Reward HP (pending)
0.000HP
HBD
hbd_balance
0.000HBD
hbd_conversions
0.000HBD
hbd_market_balance
0.000HBD
savings_hbd_balance
0.000HBD
reward_hbd_balance
0.000HBD
{
  "balance": "30.953 HIVE",
  "savings_balance": "0.000 HIVE",
  "reward_hive_balance": "0.000 HIVE",
  "vesting_shares": "822348.199557 VESTS",
  "delegated_vesting_shares": "0.000000 VESTS",
  "received_vesting_shares": "51148.767155 VESTS",
  "hbd_balance": "0.000 HBD",
  "savings_hbd_balance": "0.000 HBD",
  "reward_hbd_balance": "0.000 HBD"
}

Account Info

namefemdev
id704428
rank0
reputation0
created2018-01-31T21:13:06
recovery_accountscipio
proxyNone
invited_bynull
post_count146
comment_count0
lifetime_vote_count0
witnesses_voted_for0
last_post2026-06-06T12:53:24
last_root_post2026-06-06T12:53:24
last_vote_time2026-06-06T12:53:33
proxied_vsf_votes0, 0, 0, 0
can_vote1
voting_power9,800
delayed_votesNone
governance_vote_expiration_ts1969-12-31T23:59:59
balance30.953 HIVE
savings_balance0.000 HIVE
hbd_balance0.000 HBD
savings_hbd_balance0.000 HBD
vesting_shares822348.199557 VESTS
delegated_vesting_shares0.000000 VESTS
received_vesting_shares51148.767155 VESTS
reward_vesting_balance0.000000 VESTS
vesting_balance0.000 HIVE
vesting_withdraw_rate0.000000 VESTS
next_vesting_withdrawal1969-12-31T23:59:59
withdrawn0
to_withdraw0
withdraw_routes0
savings_withdraw_requests0
last_account_recovery1970-01-01T00:00:00
reset_accountnull
last_owner_update2018-02-01T12:37:27
last_account_update2026-02-15T00:22:57
minedNo
hbd_seconds0
hbd_last_interest_payment2019-09-23T20:53:18
savings_hbd_last_interest_payment1970-01-01T00:00:00
{
  "id": 704428,
  "name": "femdev",
  "owner": {
    "weight_threshold": 1,
    "account_auths": [],
    "key_auths": [
      [
        "STM7KZKwyibAFkYc2jb6gLZLQjPDyVuzfjnS5rU5hrurcK5fyu2Bj",
        1
      ]
    ]
  },
  "active": {
    "weight_threshold": 1,
    "account_auths": [],
    "key_auths": [
      [
        "STM7RmcM9PgA3GPg8dcPzL7LzoiSvZnUsFX4rDjoXfYVcXx4cHBqX",
        1
      ]
    ]
  },
  "posting": {
    "weight_threshold": 1,
    "account_auths": [],
    "key_auths": [
      [
        "STM7vHX7tx89iw5VMg3JmQwV4p2SV9npWmhPpWfGtUpEuaSy2GJjT",
        1
      ]
    ]
  },
  "memo_key": "STM8SuHVNJePs4kLBZarbJvzYsvenp8CVJ2gQEcJRhUoqbHuTDMps",
  "json_metadata": "{\"profile\": {\"profile_image\": \"https://iili.io/qJA9iv4.png\", \"cover_image\": \"https://iili.io/qJA5IwB.jpg\", \"name\": \"femdev\", \"about\": \"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.\", \"location\": \"Antwerpen, Belgium\"}}",
  "posting_json_metadata": "{\"profile\": {\"profile_image\": \"https://iili.io/qJA9iv4.png\", \"cover_image\": \"https://iili.io/qJA5IwB.jpg\", \"name\": \"femdev\", \"about\": \"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.\", \"location\": \"Antwerpen, Belgium\"}}",
  "proxy": "",
  "previous_owner_update": "1970-01-01T00:00:00",
  "last_owner_update": "2018-02-01T12:37:27",
  "last_account_update": "2026-02-15T00:22:57",
  "created": "2018-01-31T21:13:06",
  "mined": false,
  "recovery_account": "scipio",
  "last_account_recovery": "1970-01-01T00:00:00",
  "reset_account": "null",
  "comment_count": 0,
  "lifetime_vote_count": 0,
  "post_count": 146,
  "can_vote": true,
  "voting_manabar": {
    "current_mana": 857550618114,
    "last_update_time": 1780751157
  },
  "downvote_manabar": {
    "current_mana": 218374241677,
    "last_update_time": 1780751157
  },
  "voting_power": 9800,
  "balance": "30.953 HIVE",
  "savings_balance": "0.000 HIVE",
  "hbd_balance": "0.000 HBD",
  "hbd_seconds": "0",
  "hbd_seconds_last_update": "2019-10-11T20:02:48",
  "hbd_last_interest_payment": "2019-09-23T20:53:18",
  "savings_hbd_balance": "0.000 HBD",
  "savings_hbd_seconds": "0",
  "savings_hbd_seconds_last_update": "1970-01-01T00:00:00",
  "savings_hbd_last_interest_payment": "1970-01-01T00:00:00",
  "savings_withdraw_requests": 0,
  "reward_hbd_balance": "0.000 HBD",
  "reward_hive_balance": "0.000 HIVE",
  "reward_vesting_balance": "0.000000 VESTS",
  "reward_vesting_hive": "0.000 HIVE",
  "vesting_shares": "822348.199557 VESTS",
  "delegated_vesting_shares": "0.000000 VESTS",
  "received_vesting_shares": "51148.767155 VESTS",
  "vesting_withdraw_rate": "0.000000 VESTS",
  "post_voting_power": "873496.966712 VESTS",
  "next_vesting_withdrawal": "1969-12-31T23:59:59",
  "withdrawn": 0,
  "to_withdraw": 0,
  "withdraw_routes": 0,
  "pending_transfers": 0,
  "curation_rewards": 842,
  "posting_rewards": 1852881,
  "proxied_vsf_votes": [
    0,
    0,
    0,
    0
  ],
  "witnesses_voted_for": 0,
  "last_post": "2026-06-06T12:53:24",
  "last_root_post": "2026-06-06T12:53:24",
  "last_vote_time": "2026-06-06T12:53:33",
  "post_bandwidth": 0,
  "pending_claimed_accounts": 0,
  "governance_vote_expiration_ts": "1969-12-31T23:59:59",
  "delayed_votes": [],
  "open_recurrent_transfers": 0,
  "vesting_balance": "0.000 HIVE",
  "reputation": 0,
  "transfer_history": [],
  "market_history": [],
  "post_history": [],
  "vote_history": [],
  "other_history": [],
  "witness_votes": [],
  "tags_usage": [],
  "guest_bloggers": [],
  "rank": 0
}

Withdraw Routes

IncomingOutgoing
Empty
Empty
{
  "incoming": [],
  "outgoing": []
}
From Date
To Date
2026/06/06 14:25:00
voterstem-shturm
authorfemdev
weight133577198
rshares133577198
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.116 HBD
total vote weight1524762085786
Transaction InfoBlock #107037502/Trx 56efa2000c381b7a69d0c3a2894552f9d76d436d
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "stem-shturm",
      "author": "femdev",
      "weight": 133577198,
      "rshares": 133577198,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.116 HBD",
      "total_vote_weight": 1524762085786
    }
  ],
  "block": 107037502,
  "trx_id": "56efa2000c381b7a69d0c3a2894552f9d76d436d",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T14:25:00",
  "virtual_op": true,
  "trx_in_block": 3
}
2026/06/06 14:25:00
voterstem-shturm
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107037502/Trx 56efa2000c381b7a69d0c3a2894552f9d76d436d
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "stem-shturm",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107037502,
  "trx_id": "56efa2000c381b7a69d0c3a2894552f9d76d436d",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T14:25:00",
  "virtual_op": false,
  "trx_in_block": 3
}
2026/06/06 13:09:15
voterjeronimorubio
authorfemdev
weight11162786581
rshares11162786581
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.116 HBD
total vote weight1524628508588
Transaction InfoBlock #107035990/Trx 00f1c4037831df94ca81d40e373811586d5ab520
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "jeronimorubio",
      "author": "femdev",
      "weight": 11162786581,
      "rshares": 11162786581,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.116 HBD",
      "total_vote_weight": 1524628508588
    }
  ],
  "block": 107035990,
  "trx_id": "00f1c4037831df94ca81d40e373811586d5ab520",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T13:09:15",
  "virtual_op": true,
  "trx_in_block": 12
}
2026/06/06 13:09:15
voterjeronimorubio
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035990/Trx 00f1c4037831df94ca81d40e373811586d5ab520
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "jeronimorubio",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035990,
  "trx_id": "00f1c4037831df94ca81d40e373811586d5ab520",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T13:09:15",
  "virtual_op": false,
  "trx_in_block": 12
}
femdevclaimed reward balance: 0.637 HIVE, 0.648 HP
2026/06/06 13:06:00
accountfemdev
reward hbd0.000 HBD
reward hive0.637 HIVE
reward vests1052.362623 VESTS
Transaction InfoBlock #107035925/Trx b86fa1c3c5a66b2bfe0854947e9290fe209f5bad
View Raw JSON Data
{
  "op": [
    "claim_reward_balance",
    {
      "account": "femdev",
      "reward_hbd": "0.000 HBD",
      "reward_hive": "0.637 HIVE",
      "reward_vests": "1052.362623 VESTS"
    }
  ],
  "block": 107035925,
  "trx_id": "b86fa1c3c5a66b2bfe0854947e9290fe209f5bad",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T13:06:00",
  "virtual_op": false,
  "trx_in_block": 1
}
2026/06/06 12:58:30
votercoinmarketcal
authorfemdev
weight5296213683
rshares5296213683
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.115 HBD
total vote weight1513465722007
Transaction InfoBlock #107035775/Trx 3c7f11b6b894021a22149bf1dec011244b2ef9d3
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "coinmarketcal",
      "author": "femdev",
      "weight": 5296213683,
      "rshares": 5296213683,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.115 HBD",
      "total_vote_weight": 1513465722007
    }
  ],
  "block": 107035775,
  "trx_id": "3c7f11b6b894021a22149bf1dec011244b2ef9d3",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T12:58:30",
  "virtual_op": true,
  "trx_in_block": 4
}
2026/06/06 12:58:30
votercoinmarketcal
authorfemdev
weight2200 (22.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035775/Trx 3c7f11b6b894021a22149bf1dec011244b2ef9d3
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "coinmarketcal",
      "author": "femdev",
      "weight": 2200,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035775,
  "trx_id": "3c7f11b6b894021a22149bf1dec011244b2ef9d3",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:58:30",
  "virtual_op": false,
  "trx_in_block": 4
}
2026/06/06 12:54:18
voternewsrx
authorfemdev
weight2061355180
rshares2061355180
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.114 HBD
total vote weight1508169508324
Transaction InfoBlock #107035691/Trx 889c69b2919b67abd49260decbf2680871eef74d
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "newsrx",
      "author": "femdev",
      "weight": 2061355180,
      "rshares": 2061355180,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.114 HBD",
      "total_vote_weight": 1508169508324
    }
  ],
  "block": 107035691,
  "trx_id": "889c69b2919b67abd49260decbf2680871eef74d",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T12:54:18",
  "virtual_op": true,
  "trx_in_block": 22
}
2026/06/06 12:54:18
voternewsrx
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035691/Trx 889c69b2919b67abd49260decbf2680871eef74d
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "newsrx",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035691,
  "trx_id": "889c69b2919b67abd49260decbf2680871eef74d",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:54:18",
  "virtual_op": false,
  "trx_in_block": 22
}
2026/06/06 12:54:00
voterblue-witness
authorfemdev
weight4288425448
rshares4288425448
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.114 HBD
total vote weight1506108153144
Transaction InfoBlock #107035685/Trx 7bff839f96d7857c8a7e6d2f78f1136541cde932
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "blue-witness",
      "author": "femdev",
      "weight": 4288425448,
      "rshares": 4288425448,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.114 HBD",
      "total_vote_weight": 1506108153144
    }
  ],
  "block": 107035685,
  "trx_id": "7bff839f96d7857c8a7e6d2f78f1136541cde932",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T12:54:00",
  "virtual_op": true,
  "trx_in_block": 5
}
2026/06/06 12:54:00
voterblue-witness
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035685/Trx 7bff839f96d7857c8a7e6d2f78f1136541cde932
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "blue-witness",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035685,
  "trx_id": "7bff839f96d7857c8a7e6d2f78f1136541cde932",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:54:00",
  "virtual_op": false,
  "trx_in_block": 5
}
2026/06/06 12:53:48
votersteem-ua
authorfemdev
weight513511629356
rshares513511629356
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.114 HBD
total vote weight1501819727696
Transaction InfoBlock #107035681/Trx b07afe3d4a39fb5e1454905d5b7dc4547bd84519
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "steem-ua",
      "author": "femdev",
      "weight": 513511629356,
      "rshares": 513511629356,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.114 HBD",
      "total_vote_weight": 1501819727696
    }
  ],
  "block": 107035681,
  "trx_id": "b07afe3d4a39fb5e1454905d5b7dc4547bd84519",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T12:53:48",
  "virtual_op": true,
  "trx_in_block": 6
}
2026/06/06 12:53:48
votersteem-ua
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035681/Trx b07afe3d4a39fb5e1454905d5b7dc4547bd84519
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "steem-ua",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035681,
  "trx_id": "b07afe3d4a39fb5e1454905d5b7dc4547bd84519",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:53:48",
  "virtual_op": false,
  "trx_in_block": 6
}
2026/06/06 12:53:42
voterscipio
authorfemdev
weight201130048271
rshares201130048271
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.075 HBD
total vote weight988308098340
Transaction InfoBlock #107035679/Trx 444491f62e06f9f43ff680a8fe01facca204df7a
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "scipio",
      "author": "femdev",
      "weight": 201130048271,
      "rshares": 201130048271,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.075 HBD",
      "total_vote_weight": 988308098340
    }
  ],
  "block": 107035679,
  "trx_id": "444491f62e06f9f43ff680a8fe01facca204df7a",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T12:53:42",
  "virtual_op": true,
  "trx_in_block": 14
}
2026/06/06 12:53:42
voterscipio
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035679/Trx 444491f62e06f9f43ff680a8fe01facca204df7a
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "scipio",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035679,
  "trx_id": "444491f62e06f9f43ff680a8fe01facca204df7a",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:53:42",
  "virtual_op": false,
  "trx_in_block": 14
}
2026/06/06 12:53:36
voterfemdev
authorfemdev
weight17398892082
rshares17398892082
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.059 HBD
total vote weight787178050069
Transaction InfoBlock #107035677/Trx eb418dc9a451924d1c5d5a84a247b59e50500b89
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "femdev",
      "author": "femdev",
      "weight": 17398892082,
      "rshares": 17398892082,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.059 HBD",
      "total_vote_weight": 787178050069
    }
  ],
  "block": 107035677,
  "trx_id": "eb418dc9a451924d1c5d5a84a247b59e50500b89",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T12:53:36",
  "virtual_op": true,
  "trx_in_block": 26
}
2026/06/06 12:53:36
voterfemdev
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035677/Trx eb418dc9a451924d1c5d5a84a247b59e50500b89
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "femdev",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035677,
  "trx_id": "eb418dc9a451924d1c5d5a84a247b59e50500b89",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:53:36",
  "virtual_op": false,
  "trx_in_block": 26
}
2026/06/06 12:53:33
voterbluerobo
authorfemdev
weight769779157987
rshares769779157987
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
pending payout0.058 HBD
total vote weight769779157987
Transaction InfoBlock #107035676/Trx 7012070a5a67d2e3cde9d992bfc41c8d48146933
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "bluerobo",
      "author": "femdev",
      "weight": 769779157987,
      "rshares": 769779157987,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "pending_payout": "0.058 HBD",
      "total_vote_weight": 769779157987
    }
  ],
  "block": 107035676,
  "trx_id": "7012070a5a67d2e3cde9d992bfc41c8d48146933",
  "op_in_trx": 1,
  "timestamp": "2026-06-06T12:53:33",
  "virtual_op": true,
  "trx_in_block": 7
}
2026/06/06 12:53:33
voterbluerobo
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
Transaction InfoBlock #107035676/Trx 7012070a5a67d2e3cde9d992bfc41c8d48146933
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "bluerobo",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"
    }
  ],
  "block": 107035676,
  "trx_id": "7012070a5a67d2e3cde9d992bfc41c8d48146933",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:53:33",
  "virtual_op": false,
  "trx_in_block": 7
}
2026/06/06 12:53:30
idreblog
json["reblog", {"account": "femdev", "author": "femdev", "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language"}]
required auths[]
required posting auths["femdev"]
Transaction InfoBlock #107035675/Trx db101390b36b3e8eed5bb089d937ffe3f829dd0e
View Raw JSON Data
{
  "op": [
    "custom_json",
    {
      "id": "reblog",
      "json": "[\"reblog\", {\"account\": \"femdev\", \"author\": \"femdev\", \"permlink\": \"learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language\"}]",
      "required_auths": [],
      "required_posting_auths": [
        "femdev"
      ]
    }
  ],
  "block": 107035675,
  "trx_id": "db101390b36b3e8eed5bb089d937ffe3f829dd0e",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:53:30",
  "virtual_op": false,
  "trx_in_block": 8
}
2026/06/06 12:53:27
body# Learn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language ![cc-banner](https://images.hive.blog/DQmcX6yi9Uoqxh5UyHPgVosRfwKDaeR2JFKby1JqADcAjbD/cc-banner-amber.png) Last episode we built network visualizations -- force-directed layouts, community detection, adjacency matrices, arc diagrams, interactive hover highlighting, edge bundling. We turned structural relationships into visual patterns. Nodes and edges, clusters and bridges, the social architecture of data made visible. But all the data we've worked with so far in this arc -- geographic coordinates, timestamps, network connections -- has been inherently numeric. Numbers map naturally to visual properties: position, size, color, angle. The mapping is direct. Text is different. Text is messy. A paragraph of English isn't a number. A word isn't a coordinate. Before you can visualize text, you have to *measure* it -- turn words and sentences into quantities that your canvas can work with. Word frequency, sentence length, character distribution, sentiment scores. Once you extract those measurements, text becomes data like any other. And the patterns hiding in language turn out to be surprisingly beautiful. This episode is about treating text as raw material for creative coding. We'll count words (and discover Zipf's law along the way), measure sentence rhythm, score sentiment with a dictionary lookup, map characters and words to colors, compare two texts side by side, and generate new text with Markov chains. We parsed structured data files back in episode 81 -- CSV rows and JSON objects with neat fields. Text is the unstructured cousin: no columns, no types, just a stream of characters that your code has to make sense of. ## Word frequency: the shape of language The most basic measurement of text: how often does each word appear? Count every word, sort by frequency, and you've got a profile of what the text is *about*. The most frequent words in any English text are always the same: "the", "a", "is", "and", "of", "to". These are called stop words -- they're structural, not meaningful. Filter them out and the content words that remain tell you the subject matter. A novel about whales will have "whale", "sea", "captain" near the top. A tech blog post will have "function", "data", "code". ```javascript function countWords(text) { const words = text.toLowerCase() .replace(/[^a-z\s]/g, '') .split(/\s+/) .filter(w => w.length > 0); const counts = {}; for (const word of words) { counts[word] = (counts[word] || 0) + 1; } return Object.entries(counts) .sort((a, b) => b[1] - a[1]); } const sampleText = `The quick brown fox jumps over the lazy dog. The dog barked at the fox. The fox ran away from the dog. A cat watched the dog chase the fox from the garden wall. The garden was quiet after the fox and the dog left.`; const freq = countWords(sampleText); // [["the", 10], ["dog", 4], ["fox", 4], ["the", ...], ...] ``` Ten occurrences of "the" in four sentences. Four each for "dog" and "fox". That's already telling you something -- this text is about a dog and a fox, which of course it is, but the algorithm doesn't know that. It just counted. The `replace(/[^a-z\s]/g, '')` strip is crude but effective -- it removes punctuation, digits, and special characters, leaving only lowercase letters and spaces. A proper tokenizer would handle apostrophes ("don't" should be one word, not "don" and "t"), hyphens ("well-known"), and unicode. For creative coding purposes, the crude version usually works fine. ## Zipf's law: the universal pattern Here's something wild. If you take any sufficiently long text in any natural language and plot word frequency on a log-log scale, you get a straight line. The most frequent word appears roughly twice as often as the second most frequent, three times as often as the third, and so on. Frequency times rank equals a constant. This is Zipf's law, and it holds for English, French, Japanese, Arabic, ancient Greek -- every natural language ever studied. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // generate synthetic word frequencies following Zipf's law // (in real use, feed in actual counted words from a text) const numWords = 200; const zipfFreqs = []; const maxFreq = 5000; for (let rank = 1; rank <= numWords; rank++) { // zipf: freq = C / rank^s, where s is close to 1 const freq = maxFreq / Math.pow(rank, 1.07); zipfFreqs.push({ rank, freq }); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 500); // log-log plot const logMaxRank = Math.log10(numWords); const logMaxFreq = Math.log10(maxFreq); for (const { rank, freq } of zipfFreqs) { const logRank = Math.log10(rank); const logFreq = Math.log10(freq); const x = 60 + (logRank / logMaxRank) * 580; const y = 460 - (logFreq / logMaxFreq) * 420; ctx.beginPath(); ctx.arc(x, y, 3, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.6)'; ctx.fill(); } // reference line (perfect Zipf) ctx.beginPath(); ctx.moveTo(60, 40); ctx.lineTo(640, 460); ctx.strokeStyle = 'rgba(255, 150, 100, 0.3)'; ctx.lineWidth = 1; ctx.stroke(); ``` The dots fall on a straight line. That straight line on a log-log plot means the relationship is a power law -- a mathematical signature that appears in city populations, earthquake magnitudes, website traffic, income distributions, and apparently every language humans have ever spoken. Nobody fully agrees on *why* Zipf's law holds for language. It's one of those patterns that seem too universal to be coincidence but too mysterious to explain from first principles. For creative coding, Zipf's law means word frequency distributions always have the same shape: a few very common words, a long tail of rare words. Your visualization has to handle that extreme range. Linear mapping won't work -- the top 5 words will dominate and everything else will be invisible. Log scaling (like we used for population density in episode 83) fixes that. ## Stop words and content filtering Those ultra-frequent words -- "the", "and", "is", "of", "to", "a", "in", "that" -- are the stop words. They're grammatically essential but semantically empty. For most text visualization, you want to remove them so the meaningful words shine through. ```javascript const stopWords = new Set([ 'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can', 'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as', 'into', 'through', 'during', 'before', 'after', 'above', 'below', 'between', 'out', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how', 'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other', 'some', 'such', 'no', 'not', 'only', 'own', 'same', 'so', 'than', 'too', 'very', 'just', 'because', 'about', 'up', 'down', 'it', 'its', 'he', 'she', 'they', 'them', 'his', 'her', 'their', 'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'us', 'our', 'you', 'your', 'who', 'which', 'what' ]); function contentWords(text) { return text.toLowerCase() .replace(/[^a-z\s]/g, '') .split(/\s+/) .filter(w => w.length > 1 && !stopWords.has(w)); } function contentWordFrequency(text) { const words = contentWords(text); const counts = {}; for (const word of words) { counts[word] = (counts[word] || 0) + 1; } return Object.entries(counts).sort((a, b) => b[1] - a[1]); } ``` Now instead of "the, a, is, and, of" you get the actual subject words. Feed in a speech by Martin Luther King and you'll get "dream", "freedom", "nation", "justice" at the top. Feed in a cooking recipe and you'll get "butter", "flour", "minutes", "stir". The content filter is the difference between seeing the skeleton of English grammar (same for every text) and seeing the fingerprint of *this particular* text. There's a creative argument for keeping stop words, though. Their frequency patterns reveal writing style, not content. One author might use "however" constantly while another never does. The ratio of "I" to "we" tells you something about perspective. If you're comparing *how* two texts are written rather than *what* they're about, the stop words are the signal, not the noise. ## Sentence length: the rhythm of prose Every writer has a rhythm. Hemingway wrote short, punchy sentences. Faulkner wrote sentences that stretched across half a page, nesting clause inside clause inside clause, circling back to pick up threads that seemed abandoned paragraphs ago. Visualizing sentence length turns that rhythmic fingerprint into a visible pattern. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 300; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); function getSentenceLengths(text) { // split on . ! ? (simplified -- doesn't handle abbreviations like "Dr." or "U.S.") const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); return sentences.map(s => s.trim().split(/\s+/).length); } // simulate two different writing styles const shortStyle = []; // hemingway-ish const longStyle = []; // faulkner-ish for (let i = 0; i < 40; i++) { shortStyle.push(4 + Math.floor(Math.random() * 8)); longStyle.push(15 + Math.floor(Math.random() * 30)); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 300); const barWidth = 900 / 40 - 2; const maxLen = Math.max(...shortStyle, ...longStyle); // top row: short style for (let i = 0; i < 40; i++) { const h = (shortStyle[i] / maxLen) * 120; ctx.fillStyle = 'rgba(100, 200, 180, 0.6)'; ctx.fillRect(i * (barWidth + 2) + 1, 130 - h, barWidth, h); } // bottom row: long style for (let i = 0; i < 40; i++) { const h = (longStyle[i] / maxLen) * 120; ctx.fillStyle = 'rgba(200, 120, 180, 0.6)'; ctx.fillRect(i * (barWidth + 2) + 1, 170, barWidth, h); } ``` Two writers, same number of sentences. The top row is low and uniform -- short, choppy, rhythmically tight. The bottom row is tall and varied -- long, sprawling, rhythmically loose. You can see the writing style without reading a single word. The bar chart IS the prose rhythm, translated from temporal flow into spatial pattern. This is one of my favuorite text visualizations because it captures something that's genuinely hard to articulate in words. You *feel* writing rhythm when you read, but you can't easily describe it. "Hemingway writes short sentences" is true but doesn't tell you the *pattern* -- how his short sentences cluster, where the occasional long one breaks the rhythm for emphasis. The visualization shows all of that at once. ## Sentiment analysis: positive and negative Sentiment analysis rates text as positive, negative, or neutral. The simplest approach: use a word-level dictionary where each word has a pre-assigned score. The AFINN lexicon gives scores from -5 (very negative) to +5 (very positive) for about 2,477 English words. "Love" is +3. "Hate" is -3. "Disaster" is -3. "Excellent" is +3. Most words aren't in the dictionary at all -- they're neutral. ```javascript // simplified AFINN-style sentiment dictionary const sentiment = { 'love': 3, 'happy': 3, 'joy': 3, 'great': 3, 'excellent': 3, 'wonderful': 4, 'beautiful': 3, 'amazing': 4, 'good': 2, 'nice': 2, 'like': 1, 'best': 3, 'brilliant': 4, 'perfect': 3, 'hope': 2, 'win': 3, 'won': 3, 'success': 2, 'smile': 2, 'laugh': 1, 'hate': -3, 'terrible': -3, 'awful': -3, 'bad': -2, 'worst': -3, 'ugly': -2, 'stupid': -2, 'fail': -2, 'failed': -2, 'disaster': -3, 'horrible': -3, 'angry': -2, 'sad': -2, 'pain': -2, 'fear': -2, 'kill': -3, 'die': -2, 'death': -2, 'war': -2, 'destroy': -3, 'wrong': -2, 'broken': -1, 'hurt': -2, 'lost': -1, 'miss': -1 }; function scoreSentiment(text) { const words = text.toLowerCase().replace(/[^a-z\s]/g, '').split(/\s+/); let total = 0; let scored = 0; for (const word of words) { if (sentiment[word] !== undefined) { total += sentiment[word]; scored++; } } return { total, scored, average: scored > 0 ? total / scored : 0 }; } ``` This is crude. It doesn't understand negation ("not happy" should be negative but scores positive because "happy" is +3 and "not" isn't scored). It doesn't understand sarcasm, context, or intensity modifiers ("really happy" vs "happy"). But for creative coding, crude works. You're not building a production sentiment classifier -- you're extracting a signal that drives visual output. The imperfections add character. A text that's mostly positive with occasional negative spikes creates a visual rhythm that's more interesting than a perfectly calibrated flat score. ## Visualizing sentiment over a story The real magic happens when you score sentiment *per sentence* and plot it across a text. The emotional arc of a story becomes visible. Happy beginning, dark middle, triumphant ending? You'll see it as a color gradient shifting from warm to cold and back to warm. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 250; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // fake story: 30 sentences with an emotional arc // positive start, dip into negative, positive ending const storyScores = []; for (let i = 0; i < 30; i++) { const t = i / 29; // arc shape: starts positive, dips negative at 60%, recovers const arc = Math.sin(t * Math.PI * 2 - Math.PI * 0.3) * 2; const noise = (Math.random() - 0.5) * 1.5; storyScores.push(arc + noise); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 250); const barW = 900 / 30 - 2; for (let i = 0; i < storyScores.length; i++) { const score = storyScores[i]; const x = i * (barW + 2) + 1; // positive = warm amber, negative = cool blue const normalized = (score + 4) / 8; // roughly -4 to +4 -> 0 to 1 const hue = normalized * 40 + (1 - normalized) * 220; const lightness = 25 + Math.abs(score) * 8; const barH = Math.abs(score) * 20 + 5; const y = score >= 0 ? 125 - barH : 125; ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`; ctx.fillRect(x, y, barW, barH); } // center line ctx.beginPath(); ctx.moveTo(0, 125); ctx.lineTo(900, 125); ctx.strokeStyle = 'rgba(80, 90, 110, 0.3)'; ctx.lineWidth = 1; ctx.stroke(); ``` Warm bars above the line: positive sentiment. Cool bars below: negative. The emotional arc is visible -- optimistic opening, descent into darkness around sentence 18, recovery toward the end. If you fed in an actual novel (sentence by sentence), you'd see the emotional structure that the author built. Kurt Vonnegut famously sketched story shapes by hand -- "man in hole" (happy, falls into trouble, climbs out), "boy meets girl" (rises, falls, rises). This visualization generates those shapes automatically from the text itself. ## Text as color: every character a pixel Here's a purely aesthetic approach: map each character in a text to a color and draw them as a grid of tiny colored rectangles. No semantic analysis, no word counting. Just the visual pattern of the character stream. Different languages produce different patterns because their character frequency distributions differ. English has lots of 'e', 't', 'a'. German has lots of 'e', 'n', 'r' plus umlauts. The visual fingerprint of a language emerges from character-level coloring. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); function charToColor(ch) { const code = ch.charCodeAt(0); if (ch === ' ') return { h: 0, s: 0, l: 5 }; // spaces: near-black if (ch === '\n') return { h: 0, s: 0, l: 3 }; // newlines: very dark if (/[aeiou]/.test(ch)) { // vowels: warm tones const vowelMap = { a: 0, e: 30, i: 50, o: 25, u: 40 }; return { h: vowelMap[ch] || 20, s: 60, l: 45 }; } if (/[a-z]/.test(ch)) { // consonants: cool tones const hue = 180 + ((code - 97) / 26) * 120; return { h: hue, s: 45, l: 35 }; } if (/[0-9]/.test(ch)) return { h: 60, s: 40, l: 30 }; // digits: yellow // punctuation: bright accents return { h: 300, s: 50, l: 50 }; } const text = `the quick brown fox jumps over the lazy dog and the cat sat on the mat while the rain fell softly on the old tin roof making patterns in the dust a bird sang somewhere in the distance its melody weaving through the afternoon shadows lengthened across the garden as the sun dipped below the horizon everything was still except for the wind rustling through the dry autumn leaves`; const chars = text.split(''); const cols = 70; const cellW = 700 / cols; const cellH = cellW; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 500); for (let i = 0; i < chars.length; i++) { const col = i % cols; const row = Math.floor(i / cols); const ch = chars[i].toLowerCase(); const color = charToColor(ch); ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`; ctx.fillRect(col * cellW, row * cellH, cellW - 0.5, cellH - 0.5); } ``` Vowels glow warm (reds and oranges). Consonants sit cool (blues and greens). Spaces are near-black gaps. Punctuation pops as bright magenta accents. The visual texture of English prose becomes a fabric of warm and cool patches, with the vowel-consonant rhythm creating a kind of weave pattern. Compare this to a language with fewer vowels (like Czech or Polish) and you'd see a cooler, more blue-green image. A language with lots of vowels (like Hawaiian or Italian) would glow warmer. This technique maps the *texture* of language, not its meaning. Two texts about completely different subjects in the same language will produce similar color patterns. Two translations of the same text into different languages will look very different. The visualization captures the phonetic DNA of the language itself. ## Reading level: measuring complexity The Flesch-Kincaid readability formula estimates what grade level a text is written at. It uses two inputs: average sentence length (words per sentence) and average syllable count per word. Longer sentences and longer words mean higher reading level. It's imperfect -- "antidisestablishmentarianism" is a long word but a 10-year-old knows it -- but as a rough metric it works surprisingly well. ```javascript function countSyllables(word) { word = word.toLowerCase().replace(/[^a-z]/g, ''); if (word.length <= 3) return 1; // simple heuristic: count vowel groups const vowelGroups = word.match(/[aeiouy]+/g); let count = vowelGroups ? vowelGroups.length : 1; // silent e if (word.endsWith('e') && count > 1) count--; // words ending in 'le' after consonant if (word.endsWith('le') && word.length > 2 && !/[aeiouy]/.test(word[word.length - 3])) { count++; } return Math.max(1, count); } function fleschKincaid(text) { const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); const words = text.replace(/[^a-z\s]/gi, '').split(/\s+/).filter(w => w.length > 0); const totalSentences = sentences.length; const totalWords = words.length; const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0); const avgSentenceLen = totalWords / totalSentences; const avgSyllables = totalSyllables / totalWords; // Flesch-Kincaid Grade Level const grade = 0.39 * avgSentenceLen + 11.8 * avgSyllables - 15.59; return { grade: Math.max(0, grade), avgSentenceLen, avgSyllables }; } ``` A children's book might score grade 3-4. A newspaper article scores 8-10. Academic papers score 12-16. Feed in the text of a novel chapter by chapter and plot the grade level over the book -- you can see where the author simplifies (action scenes, dialogue) and where they get technical (exposition, world-building). The complexity map of a text is another kind of rhythm, related to but distinct from the sentiment arc. ## Word clouds: done right Word clouds are the most common text visualization and also the most criticized. They're often random, ugly, and hard to read. But the concept -- size proportional to frequency -- is sound. The execution just needs care. ```javascript const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 600; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // top 30 content words from a hypothetical text const wordData = [ ['ocean', 45], ['wave', 38], ['ship', 32], ['storm', 28], ['captain', 25], ['crew', 22], ['wind', 20], ['sail', 19], ['harbor', 17], ['coast', 16], ['night', 15], ['stars', 14], ['compass', 13], ['journey', 12], ['fog', 11], ['island', 10], ['reef', 10], ['current', 9], ['horizon', 9], ['depth', 8], ['anchor', 8], ['rope', 7], ['deck', 7], ['mast', 6], ['whale', 6], ['lighthouse', 5], ['tide', 5], ['salt', 5], ['port', 4], ['bow', 4] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 800, 600); const maxFreq = wordData[0][1]; // spiral placement: try positions along a spiral, accept if no overlap const placed = []; for (const [word, freq] of wordData) { const fontSize = 14 + (freq / maxFreq) * 48; ctx.font = `${Math.floor(fontSize)}px monospace`; const metrics = ctx.measureText(word); const wordW = metrics.width; const wordH = fontSize; // spiral outward from center until we find a free spot let px = 0, py = 0; let found = false; for (let t = 0; t < 500; t++) { const angle = t * 0.15; const radius = t * 0.8; const testX = 400 + Math.cos(angle) * radius - wordW / 2; const testY = 300 + Math.sin(angle) * radius + wordH / 3; // check overlap with placed words let overlaps = false; for (const p of placed) { if (testX < p.x + p.w + 4 && testX + wordW + 4 > p.x && testY - wordH < p.y && testY > p.y - p.h) { overlaps = true; break; } } if (!overlaps) { px = testX; py = testY; found = true; break; } } if (found) { const hue = 180 + (freq / maxFreq) * 60; const lightness = 30 + (freq / maxFreq) * 25; ctx.font = `${Math.floor(fontSize)}px monospace`; ctx.fillStyle = `hsl(${hue}, 50%, ${lightness}%)`; ctx.fillText(word, px, py); placed.push({ x: px, y: py, w: wordW, h: wordH }); } } ``` The spiral placement algorithm starts at the center and works outward, testing each position along an Archimedean spiral until it finds a spot that doesn't overlap any previously placed word. High-frequency words are placed first (they're biggest and need the most room), so they end up near the center. Low-frequency words fill the gaps around the edges. The result is compact and organized, not the random scatter that gives word clouds a bad name. The key improvement over naive word clouds: overlap detection. Without it, words stack on top of each other and become unreadable. With it, every word is visible and the size hierarchy is clean. It's still a word cloud -- purists will complain that exact comparison between similarly-sized words is hard -- but for creative coding, the visual impact is solid. ## Comparing two texts Side-by-side comparison reveals how differently two texts use language. Same visualization, different data. The contrast tells the story. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 400; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // two fake texts: one technical, one poetic const techWords = [ ['function', 22], ['data', 18], ['array', 15], ['loop', 14], ['variable', 12], ['code', 11], ['return', 10], ['string', 9], ['object', 9], ['method', 8], ['index', 7], ['value', 7], ['error', 6], ['type', 6], ['class', 5] ]; const poetWords = [ ['moon', 20], ['river', 16], ['shadow', 14], ['silence', 13], ['dream', 12], ['light', 11], ['wind', 10], ['stone', 9], ['rain', 9], ['flower', 8], ['dawn', 7], ['bird', 7], ['song', 6], ['ocean', 6], ['star', 5] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 400); const maxFreq = 22; function drawWordBars(words, startX, hue, label) { ctx.fillStyle = 'rgba(180, 180, 200, 0.5)'; ctx.font = '12px monospace'; ctx.textAlign = 'center'; ctx.fillText(label, startX + 190, 25); for (let i = 0; i < words.length; i++) { const [word, freq] = words[i]; const y = 40 + i * 23; const barW = (freq / maxFreq) * 250; ctx.fillStyle = `hsla(${hue}, 50%, 45%, 0.7)`; ctx.fillRect(startX, y, barW, 18); ctx.fillStyle = 'rgba(200, 200, 220, 0.6)'; ctx.font = '10px monospace'; ctx.textAlign = 'left'; ctx.fillText(word, startX + barW + 6, y + 13); } } drawWordBars(techWords, 40, 200, 'Technical Text'); drawWordBars(poetWords, 500, 320, 'Poetry'); ``` Two horizontal bar charts, side by side. The technical text is dominated by "function", "data", "array" -- the vocabulary of code. The poetry is dominated by "moon", "river", "shadow" -- the vocabulary of imagery. The bar lengths make the frequency differences scannable at a glance. The hue difference (cool blue for tech, warm magenta for poetry) reinforces the thematic contrast. This is the simplest form of text comparison but it's effective. You could go deeper: shared words highlighted in a third color, unique words per text emphasized, frequency ratios computed. But the basic side-by-side bar chart already communicates the core insight -- these two texts live in completely different vocabulary spaces. ## Markov chain text generation Allez, time for something different. Instead of *analyzing* text, let's *generate* it. A Markov chain builds a probability model from a source text: for each word, what words are likely to follow it? Then you generate new text by picking words according to those probabilities. The output has the same statistical texture as the input -- similar word patterns, similar rhythm -- but it's nonsense. Beautiful, evocative nonsense. ```javascript function buildMarkovChain(text, order) { const words = text.split(/\s+/); const chain = {}; for (let i = 0; i < words.length - order; i++) { const key = words.slice(i, i + order).join(' '); const next = words[i + order]; if (!chain[key]) chain[key] = []; chain[key].push(next); } return chain; } function generateText(chain, order, length) { const keys = Object.keys(chain); let current = keys[Math.floor(Math.random() * keys.length)]; const output = current.split(' '); for (let i = 0; i < length; i++) { const options = chain[current]; if (!options || options.length === 0) { current = keys[Math.floor(Math.random() * keys.length)]; continue; } const next = options[Math.floor(Math.random() * options.length)]; output.push(next); const words = output.slice(-order); current = words.join(' '); } return output.join(' '); } // example usage: const source = `the sea was calm and the ship sailed slowly through the dark water the stars reflected in the waves and the wind was gentle the captain stood on the deck watching the horizon where clouds gathered slowly the sea grew rough and the waves crashed against the hull the wind howled through the rigging and the crew worked to secure the sails`; const chain = buildMarkovChain(source, 2); const generated = generateText(chain, 2, 50); ``` Order 1 produces word salad -- each word connects to any word that ever followed it in the source, so the output jumps around randomly. Order 2 is the sweet spot for creative use: it captures two-word phrases, so the output has local coherence ("the sea", "the wind") but global incoherence (sentences don't make sense as a whole). Order 3 starts reproducing entire source sentences because three-word sequences in a short source text are often unique. For creative coding, Markov-generated text is material. You can visualize the generation process itself: draw each word as it's selected, color-coded by which source sentence it came from. The generated text becomes a visual patchwork of fragments from the original, reassembled into new patterns. The source text as DNA, the generated text as a mutant offspring. ## Visualizing generated text with provenance ```javascript const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 400; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // generate text and track which source position each word came from function generateWithProvenance(chain, order, length) { const keys = Object.keys(chain); let current = keys[Math.floor(Math.random() * keys.length)]; const output = []; const startWords = current.split(' '); for (const w of startWords) { output.push({ word: w, source: Math.random() }); } for (let i = 0; i < length; i++) { const options = chain[current]; if (!options || options.length === 0) { current = keys[Math.floor(Math.random() * keys.length)]; continue; } const idx = Math.floor(Math.random() * options.length); const next = options[idx]; output.push({ word: next, source: idx / options.length }); const words = output.slice(-order).map(o => o.word); current = words.join(' '); } return output; } const source = `the sea was calm and the ship sailed through the dark water the stars reflected in the waves and the wind was gentle the captain stood on the deck watching the horizon where clouds gathered slowly`; const chain = buildMarkovChain(source, 2); const generated = generateWithProvenance(chain, 2, 60); ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 800, 400); let x = 20; let y = 40; const lineHeight = 28; ctx.font = '14px monospace'; for (const item of generated) { const metrics = ctx.measureText(item.word + ' '); if (x + metrics.width > 780) { x = 20; y += lineHeight; } // color from source position: different hues for different "origins" const hue = item.source * 280 + 120; ctx.fillStyle = `hsl(${hue}, 50%, 50%)`; ctx.fillText(item.word, x, y); x += metrics.width; } ``` Each word is colored by where it came from in the probability table. Words that had many possible successors (high entropy) get one color. Words that came from a unique, deterministic transition get another. The color pattern reveals the structure of the Markov chain itself -- predictable passages are uniform in color, unpredictable junctions create color shifts. You're visualizing not just the text but the *generation process*. ## Creative exercise: text portrait of two texts Time to put it together. Take two short texts with different styles. Compute word frequency, average sentence length, and sentiment for each. Create a side-by-side visual portrait: a colored bar for each sentence (height from sentence length, color from sentiment), with the most frequent content words arranged around it. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // two contrasting texts const textA = `The sun rose over the mountain and light poured into the valley. Birds sang in the trees and the river sparkled. It was a beautiful morning and everything felt alive. The children ran through the meadow laughing. Flowers bloomed everywhere in brilliant colors. A gentle breeze carried the scent of pine. Life was good and the world was at peace.`; const textB = `The factory stood silent in the grey rain. Rust crept along the walls and the windows were broken. Nobody came here anymore. The machines had stopped years ago and dust covered everything. Wind whistled through the cracks. A dog wandered through the empty parking lot alone. The only sound was water dripping from the rusted pipes.`; function analyzeText(text) { const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0); return sentences.map(s => { const words = s.trim().split(/\s+/); let score = 0; for (const w of words) { const lower = w.toLowerCase().replace(/[^a-z]/g, ''); if (sentiment[lower]) score += sentiment[lower]; } return { length: words.length, sentiment: score, text: s.trim() }; }); } const analysisA = analyzeText(textA); const analysisB = analyzeText(textB); ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 500); function drawTextPortrait(analysis, startX, label) { ctx.fillStyle = 'rgba(180, 180, 200, 0.5)'; ctx.font = '12px monospace'; ctx.textAlign = 'center'; ctx.fillText(label, startX + 180, 30); const maxLen = 20; const barWidth = 360 / analysis.length - 3; for (let i = 0; i < analysis.length; i++) { const s = analysis[i]; const x = startX + i * (barWidth + 3); const barH = (Math.min(s.length, maxLen) / maxLen) * 300; const y = 420 - barH; // sentiment -> color: positive = warm amber, negative = cool blue const sentNorm = (s.sentiment + 6) / 12; const hue = sentNorm * 40 + (1 - sentNorm) * 220; const lightness = 25 + Math.abs(s.sentiment) * 5; ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`; ctx.fillRect(x, y, barWidth, barH); // sentence length number ctx.fillStyle = 'rgba(160, 170, 190, 0.4)'; ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillText(s.length, x + barWidth / 2, 440); } } drawTextPortrait(analysisA, 30, 'Sunny Valley'); drawTextPortrait(analysisB, 480, 'Abandoned Factory'); ``` The sunny valley text has warm amber bars -- positive sentiment throughout. The abandoned factory has cool blue bars -- negative sentiment. The bar heights show sentence length variation: the valley text has moderate, even sentences; the factory text has one very short sentence ("Nobody came here anymore.") that creates a visual dip. The two portraits are visually distinct even if you can't read the words. The color temperature alone tells you: one text is happy, one is not. See where this is going? :-) Text is infinite creative material. Every book, every speech, every tweet, every log file has measurable properties that map to visual channels. Word frequency drives size. Sentiment drives color. Sentence length drives rhythm. Character distribution drives texture. And Markov chains can generate new text with the statistical DNA of the original. Data art from text isn't limited to word clouds -- it's an entire visual language for the written word. The techniques from episodes 82-85 all apply here. The `map()` function from episode 82 converts word counts to pixel sizes. The log scaling from episode 83 tames Zipf's power law. The temporal layout from episode 84 works for plotting sentiment over a story. The network visualization from episode 85 could show word co-occurrence graphs. Text analysis plugs directly into the data-to-visuals pipeline we've been building. ## 't Komt erop neer... - Text is unstructured data. Before you can visualize it, you have to measure it -- word frequency, sentence length, character distribution, sentiment scores. These measurements turn prose into numbers your canvas can work with - Word frequency analysis counts each word's occurrences and sorts by count. Stop words ("the", "and", "is") dominate every English text -- filter them to reveal the content words that characterize what the text is about - Zipf's law: in any natural language, word frequency times rank is roughly constant. This power law creates extreme distributions (a few very common words, a long tail of rare ones) that require log scaling for visual display - Sentence length variation reveals writing rhythm. Short sentences mean tension. Long sentences mean exposition. Plotting sentence lengths as a bar chart turns prose rhythm into visible pattern -- you can see Hemingway vs Faulkner without reading a word - Sentiment analysis scores text as positive or negative using dictionary lookup (AFINN lexicon: -5 to +5 per word). Crude but effective for creative coding. Plot sentiment per sentence across a story to see the emotional arc -- the shape of the narrative's mood - Character-to-color mapping treats each letter as a pixel. Vowels get warm tones, consonants get cool tones, spaces stay dark. The resulting grid shows the phonetic texture of the language itself -- different languages produce different visual patterns from their character frequency distributions - Flesch-Kincaid readability scores grade level from average sentence length and syllable count. Visualizing reading level across a text shows where the author simplifies and where they get technical - Word clouds work when you use spiral placement with overlap detection. Place high-frequency words first at the center, let smaller words fill gaps outward. The overlap test is what separates readable word clouds from visual garbage - Text comparison works through side-by-side bar charts of word frequency. Two texts in different domains (technical vs poetic) occupy completely different vocabulary spaces -- the contrast is immediately visible in the bar lengths and word labels - Markov chains build probability models from source text (what words follow what other words) and generate new text with the same statistical properties. Order 2 hits the creative sweet spot: locally coherent phrases, globally nonsensical. The generated text carries the statistical DNA of the source - Visualizing Markov generation with provenance tracking (coloring each word by its origin in the probability table) reveals the structure of the generation process itself -- predictable passages vs high-entropy junctions Sallukes! Thanks for reading. X @femdev
titleLearn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language
authorfemdev
permlinklearn-creative-coding-86-text-as-data-analyzing-and-visualizing-language
json metadata{"tags": ["stem", "stemsocial", "steemstem", "programming", "creativecoding"], "app": "hiveblog/0.1", "format": "markdown", "image": ["https://images.hive.blog/DQmcX6yi9Uoqxh5UyHPgVosRfwKDaeR2JFKby1JqADcAjbD/cc-banner-amber.png"]}
parent author
parent permlinkhive-196387
Transaction InfoBlock #107035674/Trx ca077bf257ec4dfc4c8a75651960b3728c40e551
View Raw JSON Data
{
  "op": [
    "comment",
    {
      "body": "# Learn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language\n\n![cc-banner](https://images.hive.blog/DQmcX6yi9Uoqxh5UyHPgVosRfwKDaeR2JFKby1JqADcAjbD/cc-banner-amber.png)\n\nLast episode we built network visualizations -- force-directed layouts, community detection, adjacency matrices, arc diagrams, interactive hover highlighting, edge bundling. We turned structural relationships into visual patterns. Nodes and edges, clusters and bridges, the social architecture of data made visible. But all the data we've worked with so far in this arc -- geographic coordinates, timestamps, network connections -- has been inherently numeric. Numbers map naturally to visual properties: position, size, color, angle. The mapping is direct.\n\nText is different. Text is messy. A paragraph of English isn't a number. A word isn't a coordinate. Before you can visualize text, you have to *measure* it -- turn words and sentences into quantities that your canvas can work with. Word frequency, sentence length, character distribution, sentiment scores. Once you extract those measurements, text becomes data like any other. And the patterns hiding in language turn out to be surprisingly beautiful.\n\nThis episode is about treating text as raw material for creative coding. We'll count words (and discover Zipf's law along the way), measure sentence rhythm, score sentiment with a dictionary lookup, map characters and words to colors, compare two texts side by side, and generate new text with Markov chains. We parsed structured data files back in episode 81 -- CSV rows and JSON objects with neat fields. Text is the unstructured cousin: no columns, no types, just a stream of characters that your code has to make sense of.\n\n## Word frequency: the shape of language\n\nThe most basic measurement of text: how often does each word appear? Count every word, sort by frequency, and you've got a profile of what the text is *about*. The most frequent words in any English text are always the same: \"the\", \"a\", \"is\", \"and\", \"of\", \"to\". These are called stop words -- they're structural, not meaningful. Filter them out and the content words that remain tell you the subject matter. A novel about whales will have \"whale\", \"sea\", \"captain\" near the top. A tech blog post will have \"function\", \"data\", \"code\".\n\n```javascript\nfunction countWords(text) {\n  const words = text.toLowerCase()\n    .replace(/[^a-z\\s]/g, '')\n    .split(/\\s+/)\n    .filter(w => w.length > 0);\n\n  const counts = {};\n  for (const word of words) {\n    counts[word] = (counts[word] || 0) + 1;\n  }\n\n  return Object.entries(counts)\n    .sort((a, b) => b[1] - a[1]);\n}\n\nconst sampleText = `The quick brown fox jumps over the lazy dog.\nThe dog barked at the fox. The fox ran away from the dog.\nA cat watched the dog chase the fox from the garden wall.\nThe garden was quiet after the fox and the dog left.`;\n\nconst freq = countWords(sampleText);\n// [[\"the\", 10], [\"dog\", 4], [\"fox\", 4], [\"the\", ...], ...]\n```\n\nTen occurrences of \"the\" in four sentences. Four each for \"dog\" and \"fox\". That's already telling you something -- this text is about a dog and a fox, which of course it is, but the algorithm doesn't know that. It just counted.\n\nThe `replace(/[^a-z\\s]/g, '')` strip is crude but effective -- it removes punctuation, digits, and special characters, leaving only lowercase letters and spaces. A proper tokenizer would handle apostrophes (\"don't\" should be one word, not \"don\" and \"t\"), hyphens (\"well-known\"), and unicode. For creative coding purposes, the crude version usually works fine.\n\n## Zipf's law: the universal pattern\n\nHere's something wild. If you take any sufficiently long text in any natural language and plot word frequency on a log-log scale, you get a straight line. The most frequent word appears roughly twice as often as the second most frequent, three times as often as the third, and so on. Frequency times rank equals a constant. This is Zipf's law, and it holds for English, French, Japanese, Arabic, ancient Greek -- every natural language ever studied.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// generate synthetic word frequencies following Zipf's law\n// (in real use, feed in actual counted words from a text)\nconst numWords = 200;\nconst zipfFreqs = [];\nconst maxFreq = 5000;\nfor (let rank = 1; rank <= numWords; rank++) {\n  // zipf: freq = C / rank^s, where s is close to 1\n  const freq = maxFreq / Math.pow(rank, 1.07);\n  zipfFreqs.push({ rank, freq });\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 700, 500);\n\n// log-log plot\nconst logMaxRank = Math.log10(numWords);\nconst logMaxFreq = Math.log10(maxFreq);\n\nfor (const { rank, freq } of zipfFreqs) {\n  const logRank = Math.log10(rank);\n  const logFreq = Math.log10(freq);\n\n  const x = 60 + (logRank / logMaxRank) * 580;\n  const y = 460 - (logFreq / logMaxFreq) * 420;\n\n  ctx.beginPath();\n  ctx.arc(x, y, 3, 0, Math.PI * 2);\n  ctx.fillStyle = 'rgba(120, 180, 255, 0.6)';\n  ctx.fill();\n}\n\n// reference line (perfect Zipf)\nctx.beginPath();\nctx.moveTo(60, 40);\nctx.lineTo(640, 460);\nctx.strokeStyle = 'rgba(255, 150, 100, 0.3)';\nctx.lineWidth = 1;\nctx.stroke();\n```\n\nThe dots fall on a straight line. That straight line on a log-log plot means the relationship is a power law -- a mathematical signature that appears in city populations, earthquake magnitudes, website traffic, income distributions, and apparently every language humans have ever spoken. Nobody fully agrees on *why* Zipf's law holds for language. It's one of those patterns that seem too universal to be coincidence but too mysterious to explain from first principles.\n\nFor creative coding, Zipf's law means word frequency distributions always have the same shape: a few very common words, a long tail of rare words. Your visualization has to handle that extreme range. Linear mapping won't work -- the top 5 words will dominate and everything else will be invisible. Log scaling (like we used for population density in episode 83) fixes that.\n\n## Stop words and content filtering\n\nThose ultra-frequent words -- \"the\", \"and\", \"is\", \"of\", \"to\", \"a\", \"in\", \"that\" -- are the stop words. They're grammatically essential but semantically empty. For most text visualization, you want to remove them so the meaningful words shine through.\n\n```javascript\nconst stopWords = new Set([\n  'the', 'a', 'an', 'and', 'or', 'but', 'is', 'are', 'was', 'were',\n  'be', 'been', 'being', 'have', 'has', 'had', 'do', 'does', 'did',\n  'will', 'would', 'could', 'should', 'may', 'might', 'shall', 'can',\n  'of', 'to', 'in', 'for', 'on', 'with', 'at', 'by', 'from', 'as',\n  'into', 'through', 'during', 'before', 'after', 'above', 'below',\n  'between', 'out', 'off', 'over', 'under', 'again', 'further',\n  'then', 'once', 'here', 'there', 'when', 'where', 'why', 'how',\n  'all', 'each', 'every', 'both', 'few', 'more', 'most', 'other',\n  'some', 'such', 'no', 'not', 'only', 'own', 'same', 'so', 'than',\n  'too', 'very', 'just', 'because', 'about', 'up', 'down',\n  'it', 'its', 'he', 'she', 'they', 'them', 'his', 'her', 'their',\n  'this', 'that', 'these', 'those', 'i', 'me', 'my', 'we', 'us', 'our',\n  'you', 'your', 'who', 'which', 'what'\n]);\n\nfunction contentWords(text) {\n  return text.toLowerCase()\n    .replace(/[^a-z\\s]/g, '')\n    .split(/\\s+/)\n    .filter(w => w.length > 1 && !stopWords.has(w));\n}\n\nfunction contentWordFrequency(text) {\n  const words = contentWords(text);\n  const counts = {};\n  for (const word of words) {\n    counts[word] = (counts[word] || 0) + 1;\n  }\n  return Object.entries(counts).sort((a, b) => b[1] - a[1]);\n}\n```\n\nNow instead of \"the, a, is, and, of\" you get the actual subject words. Feed in a speech by Martin Luther King and you'll get \"dream\", \"freedom\", \"nation\", \"justice\" at the top. Feed in a cooking recipe and you'll get \"butter\", \"flour\", \"minutes\", \"stir\". The content filter is the difference between seeing the skeleton of English grammar (same for every text) and seeing the fingerprint of *this particular* text.\n\nThere's a creative argument for keeping stop words, though. Their frequency patterns reveal writing style, not content. One author might use \"however\" constantly while another never does. The ratio of \"I\" to \"we\" tells you something about perspective. If you're comparing *how* two texts are written rather than *what* they're about, the stop words are the signal, not the noise.\n\n## Sentence length: the rhythm of prose\n\nEvery writer has a rhythm. Hemingway wrote short, punchy sentences. Faulkner wrote sentences that stretched across half a page, nesting clause inside clause inside clause, circling back to pick up threads that seemed abandoned paragraphs ago. Visualizing sentence length turns that rhythmic fingerprint into a visible pattern.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 300;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\nfunction getSentenceLengths(text) {\n  // split on . ! ? (simplified -- doesn't handle abbreviations like \"Dr.\" or \"U.S.\")\n  const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);\n  return sentences.map(s => s.trim().split(/\\s+/).length);\n}\n\n// simulate two different writing styles\nconst shortStyle = [];  // hemingway-ish\nconst longStyle = [];   // faulkner-ish\n\nfor (let i = 0; i < 40; i++) {\n  shortStyle.push(4 + Math.floor(Math.random() * 8));\n  longStyle.push(15 + Math.floor(Math.random() * 30));\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 300);\n\nconst barWidth = 900 / 40 - 2;\nconst maxLen = Math.max(...shortStyle, ...longStyle);\n\n// top row: short style\nfor (let i = 0; i < 40; i++) {\n  const h = (shortStyle[i] / maxLen) * 120;\n  ctx.fillStyle = 'rgba(100, 200, 180, 0.6)';\n  ctx.fillRect(i * (barWidth + 2) + 1, 130 - h, barWidth, h);\n}\n\n// bottom row: long style\nfor (let i = 0; i < 40; i++) {\n  const h = (longStyle[i] / maxLen) * 120;\n  ctx.fillStyle = 'rgba(200, 120, 180, 0.6)';\n  ctx.fillRect(i * (barWidth + 2) + 1, 170, barWidth, h);\n}\n```\n\nTwo writers, same number of sentences. The top row is low and uniform -- short, choppy, rhythmically tight. The bottom row is tall and varied -- long, sprawling, rhythmically loose. You can see the writing style without reading a single word. The bar chart IS the prose rhythm, translated from temporal flow into spatial pattern.\n\nThis is one of my favuorite text visualizations because it captures something that's genuinely hard to articulate in words. You *feel* writing rhythm when you read, but you can't easily describe it. \"Hemingway writes short sentences\" is true but doesn't tell you the *pattern* -- how his short sentences cluster, where the occasional long one breaks the rhythm for emphasis. The visualization shows all of that at once.\n\n## Sentiment analysis: positive and negative\n\nSentiment analysis rates text as positive, negative, or neutral. The simplest approach: use a word-level dictionary where each word has a pre-assigned score. The AFINN lexicon gives scores from -5 (very negative) to +5 (very positive) for about 2,477 English words. \"Love\" is +3. \"Hate\" is -3. \"Disaster\" is -3. \"Excellent\" is +3. Most words aren't in the dictionary at all -- they're neutral.\n\n```javascript\n// simplified AFINN-style sentiment dictionary\nconst sentiment = {\n  'love': 3, 'happy': 3, 'joy': 3, 'great': 3, 'excellent': 3,\n  'wonderful': 4, 'beautiful': 3, 'amazing': 4, 'good': 2, 'nice': 2,\n  'like': 1, 'best': 3, 'brilliant': 4, 'perfect': 3, 'hope': 2,\n  'win': 3, 'won': 3, 'success': 2, 'smile': 2, 'laugh': 1,\n  'hate': -3, 'terrible': -3, 'awful': -3, 'bad': -2, 'worst': -3,\n  'ugly': -2, 'stupid': -2, 'fail': -2, 'failed': -2, 'disaster': -3,\n  'horrible': -3, 'angry': -2, 'sad': -2, 'pain': -2, 'fear': -2,\n  'kill': -3, 'die': -2, 'death': -2, 'war': -2, 'destroy': -3,\n  'wrong': -2, 'broken': -1, 'hurt': -2, 'lost': -1, 'miss': -1\n};\n\nfunction scoreSentiment(text) {\n  const words = text.toLowerCase().replace(/[^a-z\\s]/g, '').split(/\\s+/);\n  let total = 0;\n  let scored = 0;\n\n  for (const word of words) {\n    if (sentiment[word] !== undefined) {\n      total += sentiment[word];\n      scored++;\n    }\n  }\n\n  return { total, scored, average: scored > 0 ? total / scored : 0 };\n}\n```\n\nThis is crude. It doesn't understand negation (\"not happy\" should be negative but scores positive because \"happy\" is +3 and \"not\" isn't scored). It doesn't understand sarcasm, context, or intensity modifiers (\"really happy\" vs \"happy\"). But for creative coding, crude works. You're not building a production sentiment classifier -- you're extracting a signal that drives visual output. The imperfections add character. A text that's mostly positive with occasional negative spikes creates a visual rhythm that's more interesting than a perfectly calibrated flat score.\n\n## Visualizing sentiment over a story\n\nThe real magic happens when you score sentiment *per sentence* and plot it across a text. The emotional arc of a story becomes visible. Happy beginning, dark middle, triumphant ending? You'll see it as a color gradient shifting from warm to cold and back to warm.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 250;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// fake story: 30 sentences with an emotional arc\n// positive start, dip into negative, positive ending\nconst storyScores = [];\nfor (let i = 0; i < 30; i++) {\n  const t = i / 29;\n  // arc shape: starts positive, dips negative at 60%, recovers\n  const arc = Math.sin(t * Math.PI * 2 - Math.PI * 0.3) * 2;\n  const noise = (Math.random() - 0.5) * 1.5;\n  storyScores.push(arc + noise);\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 250);\n\nconst barW = 900 / 30 - 2;\n\nfor (let i = 0; i < storyScores.length; i++) {\n  const score = storyScores[i];\n  const x = i * (barW + 2) + 1;\n\n  // positive = warm amber, negative = cool blue\n  const normalized = (score + 4) / 8;  // roughly -4 to +4 -> 0 to 1\n  const hue = normalized * 40 + (1 - normalized) * 220;\n  const lightness = 25 + Math.abs(score) * 8;\n\n  const barH = Math.abs(score) * 20 + 5;\n  const y = score >= 0 ? 125 - barH : 125;\n\n  ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`;\n  ctx.fillRect(x, y, barW, barH);\n}\n\n// center line\nctx.beginPath();\nctx.moveTo(0, 125);\nctx.lineTo(900, 125);\nctx.strokeStyle = 'rgba(80, 90, 110, 0.3)';\nctx.lineWidth = 1;\nctx.stroke();\n```\n\nWarm bars above the line: positive sentiment. Cool bars below: negative. The emotional arc is visible -- optimistic opening, descent into darkness around sentence 18, recovery toward the end. If you fed in an actual novel (sentence by sentence), you'd see the emotional structure that the author built. Kurt Vonnegut famously sketched story shapes by hand -- \"man in hole\" (happy, falls into trouble, climbs out), \"boy meets girl\" (rises, falls, rises). This visualization generates those shapes automatically from the text itself.\n\n## Text as color: every character a pixel\n\nHere's a purely aesthetic approach: map each character in a text to a color and draw them as a grid of tiny colored rectangles. No semantic analysis, no word counting. Just the visual pattern of the character stream. Different languages produce different patterns because their character frequency distributions differ. English has lots of 'e', 't', 'a'. German has lots of 'e', 'n', 'r' plus umlauts. The visual fingerprint of a language emerges from character-level coloring.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\nfunction charToColor(ch) {\n  const code = ch.charCodeAt(0);\n\n  if (ch === ' ') return { h: 0, s: 0, l: 5 };      // spaces: near-black\n  if (ch === '\\n') return { h: 0, s: 0, l: 3 };      // newlines: very dark\n  if (/[aeiou]/.test(ch)) {\n    // vowels: warm tones\n    const vowelMap = { a: 0, e: 30, i: 50, o: 25, u: 40 };\n    return { h: vowelMap[ch] || 20, s: 60, l: 45 };\n  }\n  if (/[a-z]/.test(ch)) {\n    // consonants: cool tones\n    const hue = 180 + ((code - 97) / 26) * 120;\n    return { h: hue, s: 45, l: 35 };\n  }\n  if (/[0-9]/.test(ch)) return { h: 60, s: 40, l: 30 };  // digits: yellow\n  // punctuation: bright accents\n  return { h: 300, s: 50, l: 50 };\n}\n\nconst text = `the quick brown fox jumps over the lazy dog and the cat sat on the mat\nwhile the rain fell softly on the old tin roof making patterns in the dust\na bird sang somewhere in the distance its melody weaving through the afternoon\nshadows lengthened across the garden as the sun dipped below the horizon\neverything was still except for the wind rustling through the dry autumn leaves`;\n\nconst chars = text.split('');\nconst cols = 70;\nconst cellW = 700 / cols;\nconst cellH = cellW;\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 700, 500);\n\nfor (let i = 0; i < chars.length; i++) {\n  const col = i % cols;\n  const row = Math.floor(i / cols);\n  const ch = chars[i].toLowerCase();\n  const color = charToColor(ch);\n\n  ctx.fillStyle = `hsl(${color.h}, ${color.s}%, ${color.l}%)`;\n  ctx.fillRect(col * cellW, row * cellH, cellW - 0.5, cellH - 0.5);\n}\n```\n\nVowels glow warm (reds and oranges). Consonants sit cool (blues and greens). Spaces are near-black gaps. Punctuation pops as bright magenta accents. The visual texture of English prose becomes a fabric of warm and cool patches, with the vowel-consonant rhythm creating a kind of weave pattern. Compare this to a language with fewer vowels (like Czech or Polish) and you'd see a cooler, more blue-green image. A language with lots of vowels (like Hawaiian or Italian) would glow warmer.\n\nThis technique maps the *texture* of language, not its meaning. Two texts about completely different subjects in the same language will produce similar color patterns. Two translations of the same text into different languages will look very different. The visualization captures the phonetic DNA of the language itself.\n\n## Reading level: measuring complexity\n\nThe Flesch-Kincaid readability formula estimates what grade level a text is written at. It uses two inputs: average sentence length (words per sentence) and average syllable count per word. Longer sentences and longer words mean higher reading level. It's imperfect -- \"antidisestablishmentarianism\" is a long word but a 10-year-old knows it -- but as a rough metric it works surprisingly well.\n\n```javascript\nfunction countSyllables(word) {\n  word = word.toLowerCase().replace(/[^a-z]/g, '');\n  if (word.length <= 3) return 1;\n\n  // simple heuristic: count vowel groups\n  const vowelGroups = word.match(/[aeiouy]+/g);\n  let count = vowelGroups ? vowelGroups.length : 1;\n\n  // silent e\n  if (word.endsWith('e') && count > 1) count--;\n  // words ending in 'le' after consonant\n  if (word.endsWith('le') && word.length > 2 && !/[aeiouy]/.test(word[word.length - 3])) {\n    count++;\n  }\n\n  return Math.max(1, count);\n}\n\nfunction fleschKincaid(text) {\n  const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);\n  const words = text.replace(/[^a-z\\s]/gi, '').split(/\\s+/).filter(w => w.length > 0);\n\n  const totalSentences = sentences.length;\n  const totalWords = words.length;\n  const totalSyllables = words.reduce((sum, w) => sum + countSyllables(w), 0);\n\n  const avgSentenceLen = totalWords / totalSentences;\n  const avgSyllables = totalSyllables / totalWords;\n\n  // Flesch-Kincaid Grade Level\n  const grade = 0.39 * avgSentenceLen + 11.8 * avgSyllables - 15.59;\n  return { grade: Math.max(0, grade), avgSentenceLen, avgSyllables };\n}\n```\n\nA children's book might score grade 3-4. A newspaper article scores 8-10. Academic papers score 12-16. Feed in the text of a novel chapter by chapter and plot the grade level over the book -- you can see where the author simplifies (action scenes, dialogue) and where they get technical (exposition, world-building). The complexity map of a text is another kind of rhythm, related to but distinct from the sentiment arc.\n\n## Word clouds: done right\n\nWord clouds are the most common text visualization and also the most criticized. They're often random, ugly, and hard to read. But the concept -- size proportional to frequency -- is sound. The execution just needs care.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 800;\ncanvas.height = 600;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// top 30 content words from a hypothetical text\nconst wordData = [\n  ['ocean', 45], ['wave', 38], ['ship', 32], ['storm', 28], ['captain', 25],\n  ['crew', 22], ['wind', 20], ['sail', 19], ['harbor', 17], ['coast', 16],\n  ['night', 15], ['stars', 14], ['compass', 13], ['journey', 12], ['fog', 11],\n  ['island', 10], ['reef', 10], ['current', 9], ['horizon', 9], ['depth', 8],\n  ['anchor', 8], ['rope', 7], ['deck', 7], ['mast', 6], ['whale', 6],\n  ['lighthouse', 5], ['tide', 5], ['salt', 5], ['port', 4], ['bow', 4]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 800, 600);\n\nconst maxFreq = wordData[0][1];\n\n// spiral placement: try positions along a spiral, accept if no overlap\nconst placed = [];\n\nfor (const [word, freq] of wordData) {\n  const fontSize = 14 + (freq / maxFreq) * 48;\n  ctx.font = `${Math.floor(fontSize)}px monospace`;\n  const metrics = ctx.measureText(word);\n  const wordW = metrics.width;\n  const wordH = fontSize;\n\n  // spiral outward from center until we find a free spot\n  let px = 0, py = 0;\n  let found = false;\n\n  for (let t = 0; t < 500; t++) {\n    const angle = t * 0.15;\n    const radius = t * 0.8;\n    const testX = 400 + Math.cos(angle) * radius - wordW / 2;\n    const testY = 300 + Math.sin(angle) * radius + wordH / 3;\n\n    // check overlap with placed words\n    let overlaps = false;\n    for (const p of placed) {\n      if (testX < p.x + p.w + 4 && testX + wordW + 4 > p.x &&\n          testY - wordH < p.y && testY > p.y - p.h) {\n        overlaps = true;\n        break;\n      }\n    }\n\n    if (!overlaps) {\n      px = testX;\n      py = testY;\n      found = true;\n      break;\n    }\n  }\n\n  if (found) {\n    const hue = 180 + (freq / maxFreq) * 60;\n    const lightness = 30 + (freq / maxFreq) * 25;\n\n    ctx.font = `${Math.floor(fontSize)}px monospace`;\n    ctx.fillStyle = `hsl(${hue}, 50%, ${lightness}%)`;\n    ctx.fillText(word, px, py);\n\n    placed.push({ x: px, y: py, w: wordW, h: wordH });\n  }\n}\n```\n\nThe spiral placement algorithm starts at the center and works outward, testing each position along an Archimedean spiral until it finds a spot that doesn't overlap any previously placed word. High-frequency words are placed first (they're biggest and need the most room), so they end up near the center. Low-frequency words fill the gaps around the edges. The result is compact and organized, not the random scatter that gives word clouds a bad name.\n\nThe key improvement over naive word clouds: overlap detection. Without it, words stack on top of each other and become unreadable. With it, every word is visible and the size hierarchy is clean. It's still a word cloud -- purists will complain that exact comparison between similarly-sized words is hard -- but for creative coding, the visual impact is solid.\n\n## Comparing two texts\n\nSide-by-side comparison reveals how differently two texts use language. Same visualization, different data. The contrast tells the story.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 400;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// two fake texts: one technical, one poetic\nconst techWords = [\n  ['function', 22], ['data', 18], ['array', 15], ['loop', 14], ['variable', 12],\n  ['code', 11], ['return', 10], ['string', 9], ['object', 9], ['method', 8],\n  ['index', 7], ['value', 7], ['error', 6], ['type', 6], ['class', 5]\n];\n\nconst poetWords = [\n  ['moon', 20], ['river', 16], ['shadow', 14], ['silence', 13], ['dream', 12],\n  ['light', 11], ['wind', 10], ['stone', 9], ['rain', 9], ['flower', 8],\n  ['dawn', 7], ['bird', 7], ['song', 6], ['ocean', 6], ['star', 5]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 400);\n\nconst maxFreq = 22;\n\nfunction drawWordBars(words, startX, hue, label) {\n  ctx.fillStyle = 'rgba(180, 180, 200, 0.5)';\n  ctx.font = '12px monospace';\n  ctx.textAlign = 'center';\n  ctx.fillText(label, startX + 190, 25);\n\n  for (let i = 0; i < words.length; i++) {\n    const [word, freq] = words[i];\n    const y = 40 + i * 23;\n    const barW = (freq / maxFreq) * 250;\n\n    ctx.fillStyle = `hsla(${hue}, 50%, 45%, 0.7)`;\n    ctx.fillRect(startX, y, barW, 18);\n\n    ctx.fillStyle = 'rgba(200, 200, 220, 0.6)';\n    ctx.font = '10px monospace';\n    ctx.textAlign = 'left';\n    ctx.fillText(word, startX + barW + 6, y + 13);\n  }\n}\n\ndrawWordBars(techWords, 40, 200, 'Technical Text');\ndrawWordBars(poetWords, 500, 320, 'Poetry');\n```\n\nTwo horizontal bar charts, side by side. The technical text is dominated by \"function\", \"data\", \"array\" -- the vocabulary of code. The poetry is dominated by \"moon\", \"river\", \"shadow\" -- the vocabulary of imagery. The bar lengths make the frequency differences scannable at a glance. The hue difference (cool blue for tech, warm magenta for poetry) reinforces the thematic contrast.\n\nThis is the simplest form of text comparison but it's effective. You could go deeper: shared words highlighted in a third color, unique words per text emphasized, frequency ratios computed. But the basic side-by-side bar chart already communicates the core insight -- these two texts live in completely different vocabulary spaces.\n\n## Markov chain text generation\n\nAllez, time for something different. Instead of *analyzing* text, let's *generate* it. A Markov chain builds a probability model from a source text: for each word, what words are likely to follow it? Then you generate new text by picking words according to those probabilities. The output has the same statistical texture as the input -- similar word patterns, similar rhythm -- but it's nonsense. Beautiful, evocative nonsense.\n\n```javascript\nfunction buildMarkovChain(text, order) {\n  const words = text.split(/\\s+/);\n  const chain = {};\n\n  for (let i = 0; i < words.length - order; i++) {\n    const key = words.slice(i, i + order).join(' ');\n    const next = words[i + order];\n\n    if (!chain[key]) chain[key] = [];\n    chain[key].push(next);\n  }\n\n  return chain;\n}\n\nfunction generateText(chain, order, length) {\n  const keys = Object.keys(chain);\n  let current = keys[Math.floor(Math.random() * keys.length)];\n  const output = current.split(' ');\n\n  for (let i = 0; i < length; i++) {\n    const options = chain[current];\n    if (!options || options.length === 0) {\n      current = keys[Math.floor(Math.random() * keys.length)];\n      continue;\n    }\n\n    const next = options[Math.floor(Math.random() * options.length)];\n    output.push(next);\n\n    const words = output.slice(-order);\n    current = words.join(' ');\n  }\n\n  return output.join(' ');\n}\n\n// example usage:\nconst source = `the sea was calm and the ship sailed slowly through the\ndark water the stars reflected in the waves and the wind was gentle\nthe captain stood on the deck watching the horizon where clouds gathered\nslowly the sea grew rough and the waves crashed against the hull\nthe wind howled through the rigging and the crew worked to secure the sails`;\n\nconst chain = buildMarkovChain(source, 2);\nconst generated = generateText(chain, 2, 50);\n```\n\nOrder 1 produces word salad -- each word connects to any word that ever followed it in the source, so the output jumps around randomly. Order 2 is the sweet spot for creative use: it captures two-word phrases, so the output has local coherence (\"the sea\", \"the wind\") but global incoherence (sentences don't make sense as a whole). Order 3 starts reproducing entire source sentences because three-word sequences in a short source text are often unique.\n\nFor creative coding, Markov-generated text is material. You can visualize the generation process itself: draw each word as it's selected, color-coded by which source sentence it came from. The generated text becomes a visual patchwork of fragments from the original, reassembled into new patterns. The source text as DNA, the generated text as a mutant offspring.\n\n## Visualizing generated text with provenance\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 800;\ncanvas.height = 400;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// generate text and track which source position each word came from\nfunction generateWithProvenance(chain, order, length) {\n  const keys = Object.keys(chain);\n  let current = keys[Math.floor(Math.random() * keys.length)];\n  const output = [];\n\n  const startWords = current.split(' ');\n  for (const w of startWords) {\n    output.push({ word: w, source: Math.random() });\n  }\n\n  for (let i = 0; i < length; i++) {\n    const options = chain[current];\n    if (!options || options.length === 0) {\n      current = keys[Math.floor(Math.random() * keys.length)];\n      continue;\n    }\n\n    const idx = Math.floor(Math.random() * options.length);\n    const next = options[idx];\n    output.push({ word: next, source: idx / options.length });\n\n    const words = output.slice(-order).map(o => o.word);\n    current = words.join(' ');\n  }\n\n  return output;\n}\n\nconst source = `the sea was calm and the ship sailed through the dark water\nthe stars reflected in the waves and the wind was gentle the captain\nstood on the deck watching the horizon where clouds gathered slowly`;\n\nconst chain = buildMarkovChain(source, 2);\nconst generated = generateWithProvenance(chain, 2, 60);\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 800, 400);\n\nlet x = 20;\nlet y = 40;\nconst lineHeight = 28;\n\nctx.font = '14px monospace';\n\nfor (const item of generated) {\n  const metrics = ctx.measureText(item.word + ' ');\n\n  if (x + metrics.width > 780) {\n    x = 20;\n    y += lineHeight;\n  }\n\n  // color from source position: different hues for different \"origins\"\n  const hue = item.source * 280 + 120;\n  ctx.fillStyle = `hsl(${hue}, 50%, 50%)`;\n  ctx.fillText(item.word, x, y);\n\n  x += metrics.width;\n}\n```\n\nEach word is colored by where it came from in the probability table. Words that had many possible successors (high entropy) get one color. Words that came from a unique, deterministic transition get another. The color pattern reveals the structure of the Markov chain itself -- predictable passages are uniform in color, unpredictable junctions create color shifts. You're visualizing not just the text but the *generation process*.\n\n## Creative exercise: text portrait of two texts\n\nTime to put it together. Take two short texts with different styles. Compute word frequency, average sentence length, and sentiment for each. Create a side-by-side visual portrait: a colored bar for each sentence (height from sentence length, color from sentiment), with the most frequent content words arranged around it.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// two contrasting texts\nconst textA = `The sun rose over the mountain and light poured into the valley.\nBirds sang in the trees and the river sparkled.\nIt was a beautiful morning and everything felt alive.\nThe children ran through the meadow laughing.\nFlowers bloomed everywhere in brilliant colors.\nA gentle breeze carried the scent of pine.\nLife was good and the world was at peace.`;\n\nconst textB = `The factory stood silent in the grey rain.\nRust crept along the walls and the windows were broken.\nNobody came here anymore.\nThe machines had stopped years ago and dust covered everything.\nWind whistled through the cracks.\nA dog wandered through the empty parking lot alone.\nThe only sound was water dripping from the rusted pipes.`;\n\nfunction analyzeText(text) {\n  const sentences = text.split(/[.!?]+/).filter(s => s.trim().length > 0);\n  return sentences.map(s => {\n    const words = s.trim().split(/\\s+/);\n    let score = 0;\n    for (const w of words) {\n      const lower = w.toLowerCase().replace(/[^a-z]/g, '');\n      if (sentiment[lower]) score += sentiment[lower];\n    }\n    return { length: words.length, sentiment: score, text: s.trim() };\n  });\n}\n\nconst analysisA = analyzeText(textA);\nconst analysisB = analyzeText(textB);\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 500);\n\nfunction drawTextPortrait(analysis, startX, label) {\n  ctx.fillStyle = 'rgba(180, 180, 200, 0.5)';\n  ctx.font = '12px monospace';\n  ctx.textAlign = 'center';\n  ctx.fillText(label, startX + 180, 30);\n\n  const maxLen = 20;\n  const barWidth = 360 / analysis.length - 3;\n\n  for (let i = 0; i < analysis.length; i++) {\n    const s = analysis[i];\n    const x = startX + i * (barWidth + 3);\n    const barH = (Math.min(s.length, maxLen) / maxLen) * 300;\n    const y = 420 - barH;\n\n    // sentiment -> color: positive = warm amber, negative = cool blue\n    const sentNorm = (s.sentiment + 6) / 12;\n    const hue = sentNorm * 40 + (1 - sentNorm) * 220;\n    const lightness = 25 + Math.abs(s.sentiment) * 5;\n\n    ctx.fillStyle = `hsl(${hue}, 55%, ${lightness}%)`;\n    ctx.fillRect(x, y, barWidth, barH);\n\n    // sentence length number\n    ctx.fillStyle = 'rgba(160, 170, 190, 0.4)';\n    ctx.font = '9px monospace';\n    ctx.textAlign = 'center';\n    ctx.fillText(s.length, x + barWidth / 2, 440);\n  }\n}\n\ndrawTextPortrait(analysisA, 30, 'Sunny Valley');\ndrawTextPortrait(analysisB, 480, 'Abandoned Factory');\n```\n\nThe sunny valley text has warm amber bars -- positive sentiment throughout. The abandoned factory has cool blue bars -- negative sentiment. The bar heights show sentence length variation: the valley text has moderate, even sentences; the factory text has one very short sentence (\"Nobody came here anymore.\") that creates a visual dip. The two portraits are visually distinct even if you can't read the words. The color temperature alone tells you: one text is happy, one is not.\n\nSee where this is going? :-) Text is infinite creative material. Every book, every speech, every tweet, every log file has measurable properties that map to visual channels. Word frequency drives size. Sentiment drives color. Sentence length drives rhythm. Character distribution drives texture. And Markov chains can generate new text with the statistical DNA of the original. Data art from text isn't limited to word clouds -- it's an entire visual language for the written word.\n\nThe techniques from episodes 82-85 all apply here. The `map()` function from episode 82 converts word counts to pixel sizes. The log scaling from episode 83 tames Zipf's power law. The temporal layout from episode 84 works for plotting sentiment over a story. The network visualization from episode 85 could show word co-occurrence graphs. Text analysis plugs directly into the data-to-visuals pipeline we've been building.\n\n## 't Komt erop neer...\n\n- Text is unstructured data. Before you can visualize it, you have to measure it -- word frequency, sentence length, character distribution, sentiment scores. These measurements turn prose into numbers your canvas can work with\n- Word frequency analysis counts each word's occurrences and sorts by count. Stop words (\"the\", \"and\", \"is\") dominate every English text -- filter them to reveal the content words that characterize what the text is about\n- Zipf's law: in any natural language, word frequency times rank is roughly constant. This power law creates extreme distributions (a few very common words, a long tail of rare ones) that require log scaling for visual display\n- Sentence length variation reveals writing rhythm. Short sentences mean tension. Long sentences mean exposition. Plotting sentence lengths as a bar chart turns prose rhythm into visible pattern -- you can see Hemingway vs Faulkner without reading a word\n- Sentiment analysis scores text as positive or negative using dictionary lookup (AFINN lexicon: -5 to +5 per word). Crude but effective for creative coding. Plot sentiment per sentence across a story to see the emotional arc -- the shape of the narrative's mood\n- Character-to-color mapping treats each letter as a pixel. Vowels get warm tones, consonants get cool tones, spaces stay dark. The resulting grid shows the phonetic texture of the language itself -- different languages produce different visual patterns from their character frequency distributions\n- Flesch-Kincaid readability scores grade level from average sentence length and syllable count. Visualizing reading level across a text shows where the author simplifies and where they get technical\n- Word clouds work when you use spiral placement with overlap detection. Place high-frequency words first at the center, let smaller words fill gaps outward. The overlap test is what separates readable word clouds from visual garbage\n- Text comparison works through side-by-side bar charts of word frequency. Two texts in different domains (technical vs poetic) occupy completely different vocabulary spaces -- the contrast is immediately visible in the bar lengths and word labels\n- Markov chains build probability models from source text (what words follow what other words) and generate new text with the same statistical properties. Order 2 hits the creative sweet spot: locally coherent phrases, globally nonsensical. The generated text carries the statistical DNA of the source\n- Visualizing Markov generation with provenance tracking (coloring each word by its origin in the probability table) reveals the structure of the generation process itself -- predictable passages vs high-entropy junctions\n\nSallukes! Thanks for reading.\n\nX\n\n@femdev\n",
      "title": "Learn Creative Coding (#86) - Text as Data: Analyzing and Visualizing Language",
      "author": "femdev",
      "permlink": "learn-creative-coding-86-text-as-data-analyzing-and-visualizing-language",
      "json_metadata": "{\"tags\": [\"stem\", \"stemsocial\", \"steemstem\", \"programming\", \"creativecoding\"], \"app\": \"hiveblog/0.1\", \"format\": \"markdown\", \"image\": [\"https://images.hive.blog/DQmcX6yi9Uoqxh5UyHPgVosRfwKDaeR2JFKby1JqADcAjbD/cc-banner-amber.png\"]}",
      "parent_author": "",
      "parent_permlink": "hive-196387"
    }
  ],
  "block": 107035674,
  "trx_id": "ca077bf257ec4dfc4c8a75651960b3728c40e551",
  "op_in_trx": 0,
  "timestamp": "2026-06-06T12:53:27",
  "virtual_op": false,
  "trx_in_block": 14
}
2026/06/06 12:01:57
authorfemdev
permlinklearn-creative-coding-79-data-as-creative-material
Transaction InfoBlock #107034647/Virtual Operation 4294967295:14
View Raw JSON Data
{
  "op": [
    "comment_payout_update",
    {
      "author": "femdev",
      "permlink": "learn-creative-coding-79-data-as-creative-material"
    }
  ],
  "block": 107034647,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 14,
  "timestamp": "2026-06-06T12:01:57",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
2026/06/06 12:01:57
authorfemdev
payout0.144 HBD
permlinklearn-creative-coding-79-data-as-creative-material
author rewards1274
total payout value0.072 HBD
curator payout value0.072 HBD
beneficiary payout value0.000 HBD
Transaction InfoBlock #107034647/Virtual Operation 4294967295:13
View Raw JSON Data
{
  "op": [
    "comment_reward",
    {
      "author": "femdev",
      "payout": "0.144 HBD",
      "permlink": "learn-creative-coding-79-data-as-creative-material",
      "author_rewards": 1274,
      "total_payout_value": "0.072 HBD",
      "curator_payout_value": "0.072 HBD",
      "beneficiary_payout_value": "0.000 HBD"
    }
  ],
  "block": 107034647,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 13,
  "timestamp": "2026-06-06T12:01:57",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
femdevreceived 0.637 HIVE, 0.637 HP author reward for @femdev / learn-creative-coding-79-data-as-creative-material
2026/06/06 12:01:57
authorfemdev
permlinklearn-creative-coding-79-data-as-creative-material
hbd payout0.000 HBD
hive payout0.637 HIVE
vesting payout1034.498443 VESTS
payout must be claimedtrue
curators vesting payout2060.876804 VESTS
Transaction InfoBlock #107034647/Virtual Operation 4294967295:12
View Raw JSON Data
{
  "op": [
    "author_reward",
    {
      "author": "femdev",
      "permlink": "learn-creative-coding-79-data-as-creative-material",
      "hbd_payout": "0.000 HBD",
      "hive_payout": "0.637 HIVE",
      "vesting_payout": "1034.498443 VESTS",
      "payout_must_be_claimed": true,
      "curators_vesting_payout": "2060.876804 VESTS"
    }
  ],
  "block": 107034647,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 12,
  "timestamp": "2026-06-06T12:01:57",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
2026/06/06 12:01:57
authorfemdev
reward17.864180 VESTS
curatorfemdev
permlinklearn-creative-coding-79-data-as-creative-material
payout must be claimedtrue
Transaction InfoBlock #107034647/Virtual Operation 4294967295:6
View Raw JSON Data
{
  "op": [
    "curation_reward",
    {
      "author": "femdev",
      "reward": "17.864180 VESTS",
      "curator": "femdev",
      "permlink": "learn-creative-coding-79-data-as-creative-material",
      "payout_must_be_claimed": true
    }
  ],
  "block": 107034647,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 6,
  "timestamp": "2026-06-06T12:01:57",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
2026/06/05 12:26:39
voterstem-shturm
authorfemdev
weight133577198
rshares133577198
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.118 HBD
total vote weight1533045438341
Transaction InfoBlock #107006405/Trx 8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "stem-shturm",
      "author": "femdev",
      "weight": 133577198,
      "rshares": 133577198,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.118 HBD",
      "total_vote_weight": 1533045438341
    }
  ],
  "block": 107006405,
  "trx_id": "8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T12:26:39",
  "virtual_op": true,
  "trx_in_block": 24
}
2026/06/05 12:26:39
voterstem-shturm
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107006405/Trx 8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "stem-shturm",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107006405,
  "trx_id": "8eb35a9c5cbaa0cb8df89899f524ff2b619fd1e8",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T12:26:39",
  "virtual_op": false,
  "trx_in_block": 24
}
2026/06/05 12:03:36
voterjeronimorubio
authorfemdev
weight11162786581
rshares11162786581
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.118 HBD
total vote weight1532911861143
Transaction InfoBlock #107005945/Trx 0e1f732413e809f195f292354dcbf7dc07fd578e
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "jeronimorubio",
      "author": "femdev",
      "weight": 11162786581,
      "rshares": 11162786581,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.118 HBD",
      "total_vote_weight": 1532911861143
    }
  ],
  "block": 107005945,
  "trx_id": "0e1f732413e809f195f292354dcbf7dc07fd578e",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T12:03:36",
  "virtual_op": true,
  "trx_in_block": 4
}
2026/06/05 12:03:36
voterjeronimorubio
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005945/Trx 0e1f732413e809f195f292354dcbf7dc07fd578e
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "jeronimorubio",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005945,
  "trx_id": "0e1f732413e809f195f292354dcbf7dc07fd578e",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T12:03:36",
  "virtual_op": false,
  "trx_in_block": 4
}
2026/06/05 11:52:45
votercoinmarketcal
authorfemdev
weight5295441922
rshares5295441922
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.117 HBD
total vote weight1521749074562
Transaction InfoBlock #107005729/Trx 001d6f0405bedbc525c12340dc299446f165e7ee
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "coinmarketcal",
      "author": "femdev",
      "weight": 5295441922,
      "rshares": 5295441922,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.117 HBD",
      "total_vote_weight": 1521749074562
    }
  ],
  "block": 107005729,
  "trx_id": "001d6f0405bedbc525c12340dc299446f165e7ee",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:52:45",
  "virtual_op": true,
  "trx_in_block": 3
}
2026/06/05 11:52:45
votercoinmarketcal
authorfemdev
weight2200 (22.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005729/Trx 001d6f0405bedbc525c12340dc299446f165e7ee
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "coinmarketcal",
      "author": "femdev",
      "weight": 2200,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005729,
  "trx_id": "001d6f0405bedbc525c12340dc299446f165e7ee",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:52:45",
  "virtual_op": false,
  "trx_in_block": 3
}
2026/06/05 11:48:36
voternewsrx
authorfemdev
weight2061062843
rshares2061062843
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.117 HBD
total vote weight1516453632640
Transaction InfoBlock #107005646/Trx cebb40b779f73999c4203213ebaee49c7c8e221c
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "newsrx",
      "author": "femdev",
      "weight": 2061062843,
      "rshares": 2061062843,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.117 HBD",
      "total_vote_weight": 1516453632640
    }
  ],
  "block": 107005646,
  "trx_id": "cebb40b779f73999c4203213ebaee49c7c8e221c",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:48:36",
  "virtual_op": true,
  "trx_in_block": 9
}
2026/06/05 11:48:36
voternewsrx
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005646/Trx cebb40b779f73999c4203213ebaee49c7c8e221c
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "newsrx",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005646,
  "trx_id": "cebb40b779f73999c4203213ebaee49c7c8e221c",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:48:36",
  "virtual_op": false,
  "trx_in_block": 9
}
2026/06/05 11:48:24
voterblue-witness
authorfemdev
weight3928361729
rshares3928361729
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.117 HBD
total vote weight1514392569797
Transaction InfoBlock #107005642/Trx f20d8472c0ed0dbf673115ef74a35cf3ead6168b
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "blue-witness",
      "author": "femdev",
      "weight": 3928361729,
      "rshares": 3928361729,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.117 HBD",
      "total_vote_weight": 1514392569797
    }
  ],
  "block": 107005642,
  "trx_id": "f20d8472c0ed0dbf673115ef74a35cf3ead6168b",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:48:24",
  "virtual_op": true,
  "trx_in_block": 10
}
2026/06/05 11:48:24
voterblue-witness
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005642/Trx f20d8472c0ed0dbf673115ef74a35cf3ead6168b
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "blue-witness",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005642,
  "trx_id": "f20d8472c0ed0dbf673115ef74a35cf3ead6168b",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:48:24",
  "virtual_op": false,
  "trx_in_block": 10
}
2026/06/05 11:48:00
votersteem-ua
authorfemdev
weight513404958799
rshares513404958799
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.116 HBD
total vote weight1510464208068
Transaction InfoBlock #107005634/Trx 9be426f5eb39491664df77f4ec476943f1234dfe
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "steem-ua",
      "author": "femdev",
      "weight": 513404958799,
      "rshares": 513404958799,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.116 HBD",
      "total_vote_weight": 1510464208068
    }
  ],
  "block": 107005634,
  "trx_id": "9be426f5eb39491664df77f4ec476943f1234dfe",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:48:00",
  "virtual_op": true,
  "trx_in_block": 20
}
2026/06/05 11:48:00
votersteem-ua
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005634/Trx 9be426f5eb39491664df77f4ec476943f1234dfe
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "steem-ua",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005634,
  "trx_id": "9be426f5eb39491664df77f4ec476943f1234dfe",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:48:00",
  "virtual_op": false,
  "trx_in_block": 20
}
2026/06/05 11:47:54
voterscipio
authorfemdev
weight200348428493
rshares200348428493
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.077 HBD
total vote weight997059249269
Transaction InfoBlock #107005632/Trx 0111968d36f98d3e47ed3b046d809ac3459594bc
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "scipio",
      "author": "femdev",
      "weight": 200348428493,
      "rshares": 200348428493,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.077 HBD",
      "total_vote_weight": 997059249269
    }
  ],
  "block": 107005632,
  "trx_id": "0111968d36f98d3e47ed3b046d809ac3459594bc",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:47:54",
  "virtual_op": true,
  "trx_in_block": 30
}
2026/06/05 11:47:54
voterscipio
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005632/Trx 0111968d36f98d3e47ed3b046d809ac3459594bc
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "scipio",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005632,
  "trx_id": "0111968d36f98d3e47ed3b046d809ac3459594bc",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:47:54",
  "virtual_op": false,
  "trx_in_block": 30
}
2026/06/05 11:47:51
voterbluerobo
authorfemdev
weight769779157987
rshares769779157987
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.061 HBD
total vote weight796710820776
Transaction InfoBlock #107005631/Trx 6614562ad782832e6bfdb1768cd7c591006a16dd
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "bluerobo",
      "author": "femdev",
      "weight": 769779157987,
      "rshares": 769779157987,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.061 HBD",
      "total_vote_weight": 796710820776
    }
  ],
  "block": 107005631,
  "trx_id": "6614562ad782832e6bfdb1768cd7c591006a16dd",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:47:51",
  "virtual_op": true,
  "trx_in_block": 17
}
2026/06/05 11:47:51
voterbluerobo
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005631/Trx 6614562ad782832e6bfdb1768cd7c591006a16dd
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "bluerobo",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005631,
  "trx_id": "6614562ad782832e6bfdb1768cd7c591006a16dd",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:47:51",
  "virtual_op": false,
  "trx_in_block": 17
}
2026/06/05 11:47:51
voterfemdev
authorfemdev
weight17398892082
rshares17398892082
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.002 HBD
total vote weight26931662789
Transaction InfoBlock #107005631/Trx 279526141c597d328f62853481241e56e549d46e
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "femdev",
      "author": "femdev",
      "weight": 17398892082,
      "rshares": 17398892082,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.002 HBD",
      "total_vote_weight": 26931662789
    }
  ],
  "block": 107005631,
  "trx_id": "279526141c597d328f62853481241e56e549d46e",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:47:51",
  "virtual_op": true,
  "trx_in_block": 11
}
2026/06/05 11:47:51
voterfemdev
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005631/Trx 279526141c597d328f62853481241e56e549d46e
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "femdev",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005631,
  "trx_id": "279526141c597d328f62853481241e56e549d46e",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:47:51",
  "virtual_op": false,
  "trx_in_block": 11
}
2026/06/05 11:47:48
voterglimpsytips.dex
authorfemdev
weight9532770707
rshares9532770707
permlinklearn-creative-coding-85-network-and-graph-visualization
pending payout0.000 HBD
total vote weight9532770707
Transaction InfoBlock #107005630/Trx 3b42f814b909d2492c94443bda2e28ae0ed68de8
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "glimpsytips.dex",
      "author": "femdev",
      "weight": 9532770707,
      "rshares": 9532770707,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "pending_payout": "0.000 HBD",
      "total_vote_weight": 9532770707
    }
  ],
  "block": 107005630,
  "trx_id": "3b42f814b909d2492c94443bda2e28ae0ed68de8",
  "op_in_trx": 1,
  "timestamp": "2026-06-05T11:47:48",
  "virtual_op": true,
  "trx_in_block": 32
}
2026/06/05 11:47:48
voterglimpsytips.dex
authorfemdev
weight400 (4.00%)
permlinklearn-creative-coding-85-network-and-graph-visualization
Transaction InfoBlock #107005630/Trx 3b42f814b909d2492c94443bda2e28ae0ed68de8
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "glimpsytips.dex",
      "author": "femdev",
      "weight": 400,
      "permlink": "learn-creative-coding-85-network-and-graph-visualization"
    }
  ],
  "block": 107005630,
  "trx_id": "3b42f814b909d2492c94443bda2e28ae0ed68de8",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:47:48",
  "virtual_op": false,
  "trx_in_block": 32
}
2026/06/05 11:47:42
idreblog
json["reblog", {"account": "femdev", "author": "femdev", "permlink": "learn-creative-coding-85-network-and-graph-visualization"}]
required auths[]
required posting auths["femdev"]
Transaction InfoBlock #107005628/Trx c5493268e24e164b10db7e26db0edc511fe608cc
View Raw JSON Data
{
  "op": [
    "custom_json",
    {
      "id": "reblog",
      "json": "[\"reblog\", {\"account\": \"femdev\", \"author\": \"femdev\", \"permlink\": \"learn-creative-coding-85-network-and-graph-visualization\"}]",
      "required_auths": [],
      "required_posting_auths": [
        "femdev"
      ]
    }
  ],
  "block": 107005628,
  "trx_id": "c5493268e24e164b10db7e26db0edc511fe608cc",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:47:42",
  "virtual_op": false,
  "trx_in_block": 11
}
2026/06/05 11:47:42
body# Learn Creative Coding (#85) - Network and Graph Visualization ![cc-banner](https://images.hive.blog/DQmZCVibociQsR6XsLDTrnzNBjz47h1z3ocJGFZgerPf7xM/cc-banner-red.png) Last episode we visualized time -- timelines, spirals, calendar heatmaps, clock diagrams, streamgraphs, animated playback, and those dense hourly-by-daily fingerprint grids that cram 8,760 data points into a single image. Time is the most common data dimension and the way you lay it out on canvas determines which patterns appear. Straight lines ask "what happened?" Spirals ask "what repeats?" The representation IS the question. But not all data is about when or where. Some data is about *who connects to whom*. Social networks, hyperlink graphs, citation networks, character co-occurrence in novels, function call trees in codebases, biological pathways, trade routes between countries. The underlying structure isn't a sequence or a grid -- it's a *graph*. Nodes and edges. Entities and relationships. And the visual language for rendering them is completely different from anything we've built so far. This episode is about network visualization. We'll build force-directed layouts from scratch (springs and charges, simulated until equilibrium), encode node importance through size and color, detect visual communities, try alternative layouts like adjacency matrices and arc diagrams, and add the interactivity that makes dense networks actually explorable. We used physics simulation back in episode 18 for springs and flocking -- now those same forces arrange data instead of particles. ## What's a graph, visually? A graph has two things: nodes (the entities) and edges (the connections between them). A social network: nodes are people, edges are friendships. A citation network: nodes are papers, edges are references. A hyperlink graph: nodes are web pages, edges are links. The internet itself is a graph. Your brain is a graph. Most complex systems are. Visually, the classic representation is a node-link diagram: circles for nodes, lines between them for edges. Simple in concept. Nightmarishly complex in practice when you have more then about 20 nodes, because the layout problem -- where do you *put* each node so the picture is readable? -- is genuinely hard. There's no single right answer. The same graph can look like a mess or a revelation depending on where you position the nodes. ```javascript const canvas = document.createElement('canvas'); canvas.width = 600; canvas.height = 600; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // a small graph: 8 nodes, some edges const nodes = [ { id: 0, x: 150, y: 150 }, { id: 1, x: 300, y: 100 }, { id: 2, x: 450, y: 150 }, { id: 3, x: 100, y: 350 }, { id: 4, x: 300, y: 300 }, { id: 5, x: 500, y: 350 }, { id: 6, x: 200, y: 480 }, { id: 7, x: 400, y: 480 } ]; const edges = [ [0, 1], [1, 2], [0, 4], [1, 4], [2, 5], [3, 4], [3, 6], [4, 5], [4, 7], [5, 7], [6, 7] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 600, 600); // draw edges for (const [a, b] of edges) { ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.strokeStyle = 'rgba(100, 140, 200, 0.3)'; ctx.lineWidth = 1.5; ctx.stroke(); } // draw nodes for (const node of nodes) { ctx.beginPath(); ctx.arc(node.x, node.y, 8, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.7)'; ctx.fill(); ctx.strokeStyle = 'rgba(160, 200, 255, 0.4)'; ctx.lineWidth = 1; ctx.stroke(); } ``` Eight nodes, eleven edges. I placed them by hand at positions that look reasonable. But that's the problem -- I *chose* those positions. They don't come from the data. If you add a 9th node, where does it go? If you have 200 nodes? Manual placement doesn't scale. We need an algorithm that figures out good positions automatically. ## Force-directed layout: physics as design This is the big one. The idea: treat each edge as a spring (it pulls connected nodes together) and treat each pair of nodes as repelling charges (they push apart, like magnets with the same polarity). Then simulate the physics. Connected nodes get pulled close. Unconnected nodes get pushed away. After enough iterations, the system reaches equilibrium -- a layout where the forces balance out. Clusters of densely connected nodes end up near each other, and loosely connected parts drift apart. It's the same spring physics we built in episode 18, but applied to layout instead of animation. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 700; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // generate a random graph const numNodes = 30; const nodes = []; for (let i = 0; i < numNodes; i++) { nodes.push({ x: 200 + Math.random() * 300, y: 200 + Math.random() * 300, vx: 0, vy: 0 }); } // random edges -- each node connects to 2-4 others const edges = []; for (let i = 0; i < numNodes; i++) { const numEdges = 2 + Math.floor(Math.random() * 3); for (let e = 0; e < numEdges; e++) { const j = Math.floor(Math.random() * numNodes); if (j !== i) { edges.push([i, j]); } } } function simulate() { const repulsion = 5000; const springStrength = 0.01; const springLength = 80; const damping = 0.85; // repulsion between all node pairs for (let i = 0; i < numNodes; i++) { for (let j = i + 1; j < numNodes; j++) { const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dist = Math.sqrt(dx * dx + dy * dy) + 0.1; const force = repulsion / (dist * dist); const fx = (dx / dist) * force; const fy = (dy / dist) * force; nodes[i].vx -= fx; nodes[i].vy -= fy; nodes[j].vx += fx; nodes[j].vy += fy; } } // spring attraction along edges for (const [a, b] of edges) { const dx = nodes[b].x - nodes[a].x; const dy = nodes[b].y - nodes[a].y; const dist = Math.sqrt(dx * dx + dy * dy) + 0.1; const displacement = dist - springLength; const fx = (dx / dist) * displacement * springStrength; const fy = (dy / dist) * displacement * springStrength; nodes[a].vx += fx; nodes[a].vy += fy; nodes[b].vx -= fx; nodes[b].vy -= fy; } // centering force (gentle pull toward canvas center) for (const node of nodes) { node.vx += (350 - node.x) * 0.001; node.vy += (350 - node.y) * 0.001; } // update positions with damping for (const node of nodes) { node.vx *= damping; node.vy *= damping; node.x += node.vx; node.y += node.vy; } } function draw() { ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); // edges for (const [a, b] of edges) { ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)'; ctx.lineWidth = 1; ctx.stroke(); } // nodes for (const node of nodes) { ctx.beginPath(); ctx.arc(node.x, node.y, 5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.7)'; ctx.fill(); } simulate(); requestAnimationFrame(draw); } draw(); ``` Thirty nodes, random edges. They start in a clump and then -- over a few seconds -- they sort themselves out. Connected groups drift together. Bridges between groups stretch into visible corridors. Isolated nodes float to the edges. The layout *emerges* from the physics. You didn't design it. The forces did. The three forces at work: 1. **Repulsion** (all pairs): Coulomb's law in reverse. Every node pushes every other node away. The force drops off with the square of the distance -- close nodes push hard, far nodes barely affect each other. This prevents everything from collapsing into a single point. 2. **Spring attraction** (edges only): Hooke's law. Connected nodes are pulled toward a target distance (`springLength`). If they're farther apart, the spring pulls them together. If they're closer, it pushes them apart (yes, springs do that too). This keeps connected nodes near each other. 3. **Centering** (all nodes): A weak pull toward the center of the canvas. Without this, the whole graph drifts off-screen because repulsion pushes everything outward with no counterweight. The `damping` factor (0.85) acts like friction. Each frame, velocities are reduced by 15%. Without damping, the system oscillates forever -- nodes bounce back and forth and never settle. With damping, the kinetic energy drains away and the system converges to a stable layout. ## Visual encoding: making nodes meaningful A graph where every node looks the same is a graph that's hiding information. In most real networks, nodes have properties: importance, category, size of following, number of connections. The visual encoding of those properties turns a structural diagram into a data visualization. The most important node property is **degree** -- how many edges connect to it. High-degree nodes are hubs. They hold the network together. Low-degree nodes are peripheral. You can compute degree just by counting edges per node. ```javascript // compute degree for each node const degree = new Array(numNodes).fill(0); for (const [a, b] of edges) { degree[a]++; degree[b]++; } const maxDeg = Math.max(...degree); // draw nodes with size and color from degree for (let i = 0; i < numNodes; i++) { const node = nodes[i]; const d = degree[i]; // area-proportional sizing (ep082 lesson) const area = (d / maxDeg) * 800 + 50; const r = Math.sqrt(area / Math.PI); // color: low degree = cool blue, high degree = warm amber const hue = 220 - (d / maxDeg) * 180; const lightness = 30 + (d / maxDeg) * 25; ctx.beginPath(); ctx.arc(node.x, node.y, r, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 55%, ${lightness}%, 0.7)`; ctx.fill(); } ``` Now the hub nodes are large and warm-colored. Peripheral nodes are small and blue. You can immediately see which nodes hold the network together without counting edges manually. The area-proportional sizing we learned in episode 82 matters here too -- mapping degree to radius instead of area would exaggerate the visual importance of hubs. `Math.sqrt(area / Math.PI)` keeps it honest. Edge thickness can carry meaning too. If your edges have weights (frequency of communication, number of shared connections, traffic volume), thicker edges mean stronger relationships: ```javascript // draw edges with thickness from weight for (const edge of weightedEdges) { ctx.beginPath(); ctx.moveTo(nodes[edge.source].x, nodes[edge.source].y); ctx.lineTo(nodes[edge.target].x, nodes[edge.target].y); const thickness = 0.5 + (edge.weight / maxWeight) * 4; ctx.strokeStyle = `rgba(80, 120, 180, ${0.1 + (edge.weight / maxWeight) * 0.4})`; ctx.lineWidth = thickness; ctx.stroke(); } ``` Thick bright edges are strong connections. Thin faint edges are weak ties. The network's backbone becomes visible -- the thick edges form the structural core, and the thin ones are the periphery. ## Community detection by color Real networks have communities -- groups of nodes that are more connected to each other than to the rest of the network. Social cliques, academic departments, music genre clusters. Visually, communities should have distinct colors so you can see the group structure at a glance. A simple (not academically rigorous but visually effective) approach: assign each node to the community of its most-connected neighbor. Repeat a few times until it converges. It's a form of label propagation. ```javascript // simple community detection: label propagation const community = []; for (let i = 0; i < numNodes; i++) { community[i] = i; // each node starts as its own community } // build adjacency list const neighbors = Array.from({ length: numNodes }, () => []); for (const [a, b] of edges) { neighbors[a].push(b); neighbors[b].push(a); } // iterate: each node adopts the most common label among neighbors for (let iter = 0; iter < 10; iter++) { for (let i = 0; i < numNodes; i++) { if (neighbors[i].length === 0) continue; const counts = {}; for (const n of neighbors[i]) { const label = community[n]; counts[label] = (counts[label] || 0) + 1; } // find the most frequent label let bestLabel = community[i]; let bestCount = 0; for (const [label, count] of Object.entries(counts)) { if (count > bestCount) { bestCount = count; bestLabel = parseInt(label); } } community[i] = bestLabel; } } // map community labels to colors const uniqueCommunities = [...new Set(community)]; const communityColors = {}; for (let i = 0; i < uniqueCommunities.length; i++) { communityColors[uniqueCommunities[i]] = (i * 137) % 360; // golden angle for good spread } // draw with community colors for (let i = 0; i < numNodes; i++) { const node = nodes[i]; const hue = communityColors[community[i]]; ctx.beginPath(); ctx.arc(node.x, node.y, 6, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`; ctx.fill(); } ``` After a few iterations, connected clusters converge on the same label. The golden angle spacing (137 degrees between successive hues) ensures that nearby community indices don't get similar colors -- same trick that sunflowers use to pack seeds efficiently. The result: clusters of same-colored nodes with differently-colored bridges between them. The community structure that's implicit in the edge list becomes explicit in the color map. ## The adjacency matrix: when node-link fails Node-link diagrams break down when graphs are dense. Above ~100 nodes with many edges, the drawing becomes a hairball -- a tangle of crossing lines where no structure is visible. The adjacency matrix is the alternative. An adjacency matrix has one row and one column per node. The cell at row i, column j is filled if there's an edge between nodes i and j. It's a 2D grid representation of the same information as the node-link diagram, but it never has crossing edges because there are no edges -- just cells. ```javascript const canvas = document.createElement('canvas'); canvas.width = 500; canvas.height = 500; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); const n = 25; // generate a clustered adjacency matrix const matrix = Array.from({ length: n }, () => new Array(n).fill(0)); // three clusters: 0-8, 9-16, 17-24 function sameCluster(i, j) { return Math.floor(i / 9) === Math.floor(j / 9); } for (let i = 0; i < n; i++) { for (let j = i + 1; j < n; j++) { const prob = sameCluster(i, j) ? 0.6 : 0.05; if (Math.random() < prob) { matrix[i][j] = 1; matrix[j][i] = 1; } } } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 500, 500); const cellSize = 16; const offset = 40; for (let i = 0; i < n; i++) { for (let j = 0; j < n; j++) { const x = offset + j * cellSize; const y = offset + i * cellSize; if (matrix[i][j]) { const cluster = Math.floor(i / 9); const hues = [200, 130, 350]; ctx.fillStyle = `hsla(${hues[cluster]}, 50%, 45%, 0.7)`; } else { ctx.fillStyle = 'rgba(25, 30, 40, 0.5)'; } ctx.fillRect(x, y, cellSize - 1, cellSize - 1); } } ``` The three clusters show up as dense colored blocks along the diagonal. Connections between clusters are sparse dots scattered off-diagonal. The block structure is unmistakable -- you can see exactly how many inter-cluster connections exist by counting the off-diagonal dots. In a node-link diagram of the same graph, those inter-cluster edges would be tangled lines crossing through the clusters and the block structure would be invisible. Adjacency matrices have a crucial property: the ordering of rows and columns matters enormously. If you shuffle the node order randomly, the blocks disappear -- the same connections become scattered cells with no visible pattern. A good adjacency matrix requires sorting nodes so that communities end up adjacent. That ordering IS the analysis -- getting it right reveals the structure, getting it wrong hides it. ## Arc diagram: the literary layout An arc diagram places all nodes along a horizontal line and draws edges as arcs above it. The arc height is proportional to the distance between connected nodes in the linear ordering. Nearby connections are short arcs. Long-range connections are tall arcs that reach across the diagram. This layout works beautifully for sequential data. Characters in a book: who interacts with whom, and how far apart are they in the narrative? Function calls in a program: which functions call which other functions, and how far apart are they in the source file? Any data with a natural linear ordering benefits from the arc layout. ```javascript const canvas = document.createElement('canvas'); canvas.width = 900; canvas.height = 350; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // 15 "characters" in a story, with interactions const characters = [ 'Alice', 'Bob', 'Carol', 'Dan', 'Eve', 'Frank', 'Grace', 'Hank', 'Iris', 'Jack', 'Kate', 'Leo', 'Mia', 'Nate', 'Olive' ]; const interactions = [ [0, 1, 8], [0, 2, 3], [1, 3, 5], [2, 3, 6], [3, 5, 2], [4, 6, 7], [5, 7, 4], [6, 8, 3], [7, 9, 5], [8, 10, 2], [9, 11, 6], [10, 12, 4], [11, 13, 3], [12, 14, 5], [0, 14, 1], [3, 10, 2], [1, 7, 3], [5, 12, 2] // [source, target, weight] ]; ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 900, 350); const y = 280; const startX = 50; const spacing = 55; // draw arcs for (const [a, b, w] of interactions) { const x1 = startX + a * spacing; const x2 = startX + b * spacing; const midX = (x1 + x2) / 2; const arcHeight = Math.abs(b - a) * 18; ctx.beginPath(); ctx.moveTo(x1, y); ctx.quadraticCurveTo(midX, y - arcHeight, x2, y); ctx.strokeStyle = `rgba(140, 180, 255, ${0.15 + (w / 8) * 0.4})`; ctx.lineWidth = 0.5 + (w / 8) * 3; ctx.stroke(); } // draw nodes for (let i = 0; i < characters.length; i++) { const x = startX + i * spacing; ctx.beginPath(); ctx.arc(x, y, 4, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.8)'; ctx.fill(); ctx.save(); ctx.translate(x, y + 12); ctx.rotate(-Math.PI / 4); ctx.fillStyle = 'rgba(160, 170, 190, 0.5)'; ctx.font = '9px monospace'; ctx.textAlign = 'right'; ctx.fillText(characters[i], 0, 0); ctx.restore(); } ``` The long arc from Alice (position 0) to Olive (position 14) towers over the short arcs between adjacent characters. Edge thickness and opacity encode interaction frequency -- thicker arcs mean more interaction. You can see the narrative structure: most interactions happen between nearby characters (short arcs, dense connections), with a few long-range relationships bridging distant parts of the story. Arc diagrams are one of my favourite graph layouts for creative coding because they have strong visual rhythm. The nested arcs create a kind of topographic contour that's inherently beautiful even before you think about what the data means. :-) ## Interactive exploration: hover and highlight Networks are too complex for purely static views. With 30+ nodes, the viewer needs to be able to focus on one node at a time and see its neighborhood highlighted while everything else fades. This is where mouse interaction becomes essential. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 700; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // reuse force-directed layout from earlier // (assume nodes[] and edges[] are already computed and settled) let hoveredNode = -1; canvas.addEventListener('mousemove', function(e) { const rect = canvas.getBoundingClientRect(); const mx = e.clientX - rect.left; const my = e.clientY - rect.top; hoveredNode = -1; for (let i = 0; i < nodes.length; i++) { const dx = mx - nodes[i].x; const dy = my - nodes[i].y; if (dx * dx + dy * dy < 15 * 15) { hoveredNode = i; break; } } }); function drawInteractive() { ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); // find neighbors of hovered node const highlighted = new Set(); const highlightedEdges = new Set(); if (hoveredNode >= 0) { highlighted.add(hoveredNode); for (let e = 0; e < edges.length; e++) { const [a, b] = edges[e]; if (a === hoveredNode || b === hoveredNode) { highlighted.add(a); highlighted.add(b); highlightedEdges.add(e); } } } // draw edges for (let e = 0; e < edges.length; e++) { const [a, b] = edges[e]; ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); if (hoveredNode >= 0) { ctx.strokeStyle = highlightedEdges.has(e) ? 'rgba(120, 180, 255, 0.6)' : 'rgba(60, 70, 90, 0.1)'; ctx.lineWidth = highlightedEdges.has(e) ? 2 : 0.5; } else { ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)'; ctx.lineWidth = 1; } ctx.stroke(); } // draw nodes for (let i = 0; i < nodes.length; i++) { const isHighlighted = hoveredNode < 0 || highlighted.has(i); ctx.beginPath(); ctx.arc(nodes[i].x, nodes[i].y, isHighlighted ? 7 : 5, 0, Math.PI * 2); ctx.fillStyle = isHighlighted ? 'rgba(120, 180, 255, 0.8)' : 'rgba(50, 60, 80, 0.3)'; ctx.fill(); } requestAnimationFrame(drawInteractive); } drawInteractive(); ``` When you hover over a node, its edges light up and its direct neighbors stay visible while everything else fades to near-invisibility. The ego-network (a node plus its connections) pops out of the complexity. Move to a different node and a completely different neighborhood emerges. The same graph looks different from every node's perspective -- which is actually a deep truth about networks. Your experience of a social network depends entirely on where *you* sit in it. ## Edge bundling: taming the hairball When a graph has hundreds of edges, even a good force-directed layout produces visual clutter. Edges cross each other in every direction and the structural patterns disappear into spaghetti. Edge bundling is a technique that routes nearby edges along shared paths, like cables bundled into conduits. Parallel edges merge into streams, then diverge to their individual endpoints. The simplest approach: for each edge, instead of drawing a straight line from source to target, draw a bezier curve that bends toward the midpoint of the canvas (or toward the centroid of the graph). Edges with nearby endpoints share similar curves, creating the illusion of bundles. ```javascript // simple edge bundling: curve edges toward center const cx = 350; const cy = 350; const bundleStrength = 0.4; for (const [a, b] of edges) { const x1 = nodes[a].x; const y1 = nodes[a].y; const x2 = nodes[b].x; const y2 = nodes[b].y; // control point pulled toward center const midX = (x1 + x2) / 2; const midY = (y1 + y2) / 2; const ctrlX = midX + (cx - midX) * bundleStrength; const ctrlY = midY + (cy - midY) * bundleStrength; ctx.beginPath(); ctx.moveTo(x1, y1); ctx.quadraticCurveTo(ctrlX, ctrlY, x2, y2); ctx.strokeStyle = 'rgba(80, 140, 220, 0.08)'; ctx.lineWidth = 1; ctx.stroke(); } ``` The `bundleStrength` parameter controls how aggressively edges are pulled toward center. At 0 you get straight lines (no bundling). At 1 all edges pass through the center (maximally bundled, but you lose endpoint information). 0.3-0.5 is usually a good range -- enough bundling to reveal flow patterns, but the individual edge endpoints are still distinguisable. With many edges at low opacity, the bundles emerge naturally from the overlapping curves. Dense bundles -- where many edges between two clusters merge into a thick stream -- glow brightly. Isolated edges stay faint. The major highways of the network become visible while the side streets fade into the background. ## Using d3-force for production layouts Building force simulation from scratch is educational (we just did it), but for production work you'd use d3-force. It's the gold standard force-directed layout engine, and you can import just the force module without the rest of D3. It handles all the physics, adaptive cooling, collision detection, and configurable force parameters. ```javascript // d3-force handles layout, we handle rendering with canvas // import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force'; const simulation = d3.forceSimulation(nodes) .force('charge', d3.forceManyBody().strength(-200)) .force('link', d3.forceLink(edges).distance(60)) .force('center', d3.forceCenter(350, 350)) .on('tick', draw); function draw() { ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); // d3 updates node.x and node.y automatically for (const edge of edges) { ctx.beginPath(); ctx.moveTo(edge.source.x, edge.source.y); ctx.lineTo(edge.target.x, edge.target.y); ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)'; ctx.lineWidth = 1; ctx.stroke(); } for (const node of nodes) { ctx.beginPath(); ctx.arc(node.x, node.y, 5, 0, Math.PI * 2); ctx.fillStyle = 'rgba(120, 180, 255, 0.7)'; ctx.fill(); } } ``` The key difference from our DIY version: d3-force uses adaptive cooling. The simulation starts "hot" (large movements each tick) and gradually cools down (smaller and smaller movements) until it stops. Our manual version used constant damping, which works but takes longer to settle. D3's cooling schedule converges faster and produces smoother results. You don't need to use D3 for rendering -- that's the whole point. D3 calculates the positions, and you render with canvas (or p5, or WebGL, or whatever). Separation of layout from rendering. The layout engine is a tool, the visual style is yours. ## Creative exercise: character co-occurrence network Allez, time to build something real. A character co-occurrence network from a novel. Two characters are connected if they appear in the same paragraph. The more paragraphs they share, the stronger the connection. Node size from total appearances, edge weight from co-occurrence count. Force-directed layout, colored by community. We'll use a simplified dataset -- manually defining character appearances would take forever for a full novel, so we'll simulate the pattern with a fake "book" that has the right statistical structure. ```javascript const canvas = document.createElement('canvas'); canvas.width = 800; canvas.height = 800; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // simulated book characters with appearance counts and co-occurrence const characters = [ { name: 'Protagonist', appearances: 180, group: 0 }, { name: 'Mentor', appearances: 95, group: 0 }, { name: 'Sidekick', appearances: 120, group: 0 }, { name: 'Villain', appearances: 85, group: 1 }, { name: 'Henchman1', appearances: 40, group: 1 }, { name: 'Henchman2', appearances: 35, group: 1 }, { name: 'Love Interest', appearances: 75, group: 0 }, { name: 'Rival', appearances: 60, group: 2 }, { name: 'Friend1', appearances: 45, group: 2 }, { name: 'Friend2', appearances: 50, group: 2 }, { name: 'Elder', appearances: 30, group: 0 }, { name: 'Spy', appearances: 55, group: 1 }, { name: 'Narrator', appearances: 70, group: 0 }, { name: 'Merchant', appearances: 25, group: 2 }, { name: 'Guard', appearances: 20, group: 1 } ]; // co-occurrence edges (source, target, shared paragraphs) const cooccurrence = [ [0, 1, 45], [0, 2, 70], [0, 3, 30], [0, 6, 40], [0, 7, 25], [0, 12, 35], [1, 2, 20], [1, 10, 15], [1, 12, 18], [2, 6, 15], [2, 7, 12], [3, 4, 30], [3, 5, 25], [3, 11, 22], [4, 5, 18], [4, 14, 12], [5, 14, 10], [6, 12, 8], [7, 8, 20], [7, 9, 18], [8, 9, 25], [8, 13, 10], [9, 13, 8], [10, 12, 6], [11, 3, 15], [11, 14, 8], [0, 11, 10], [2, 3, 8] ]; // initialize node positions const nodes = characters.map((c, i) => ({ x: 400 + (Math.random() - 0.5) * 200, y: 400 + (Math.random() - 0.5) * 200, vx: 0, vy: 0, ...c })); // run force simulation for 200 steps for (let step = 0; step < 200; step++) { const cooling = 1.0 - step / 200; // repulsion for (let i = 0; i < nodes.length; i++) { for (let j = i + 1; j < nodes.length; j++) { const dx = nodes[j].x - nodes[i].x; const dy = nodes[j].y - nodes[i].y; const dist = Math.sqrt(dx * dx + dy * dy) + 1; const force = 4000 / (dist * dist); const fx = (dx / dist) * force * cooling; const fy = (dy / dist) * force * cooling; nodes[i].vx -= fx; nodes[i].vy -= fy; nodes[j].vx += fx; nodes[j].vy += fy; } } // spring attraction for (const [a, b, w] of cooccurrence) { const dx = nodes[b].x - nodes[a].x; const dy = nodes[b].y - nodes[a].y; const dist = Math.sqrt(dx * dx + dy * dy) + 1; const strength = 0.005 + (w / 70) * 0.01; const target = 70; const disp = dist - target; const fx = (dx / dist) * disp * strength * cooling; const fy = (dy / dist) * disp * strength * cooling; nodes[a].vx += fx; nodes[a].vy += fy; nodes[b].vx -= fx; nodes[b].vy -= fy; } // center + damping for (const node of nodes) { node.vx += (400 - node.x) * 0.002; node.vy += (400 - node.y) * 0.002; node.vx *= 0.8; node.vy *= 0.8; node.x += node.vx; node.y += node.vy; } } // draw ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 800, 800); const groupHues = [200, 350, 130]; const maxApp = Math.max(...characters.map(c => c.appearances)); const maxW = Math.max(...cooccurrence.map(e => e[2])); // edges for (const [a, b, w] of cooccurrence) { ctx.beginPath(); ctx.moveTo(nodes[a].x, nodes[a].y); ctx.lineTo(nodes[b].x, nodes[b].y); ctx.strokeStyle = `rgba(100, 130, 180, ${0.05 + (w / maxW) * 0.3})`; ctx.lineWidth = 0.5 + (w / maxW) * 3; ctx.stroke(); } // nodes for (let i = 0; i < nodes.length; i++) { const n = nodes[i]; const area = (n.appearances / maxApp) * 1200 + 80; const r = Math.sqrt(area / Math.PI); const hue = groupHues[n.group]; // glow ctx.beginPath(); ctx.arc(n.x, n.y, r + 4, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 50%, 50%, 0.12)`; ctx.fill(); // core ctx.beginPath(); ctx.arc(n.x, n.y, r, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`; ctx.fill(); // label ctx.fillStyle = 'rgba(180, 190, 210, 0.5)'; ctx.font = '9px monospace'; ctx.textAlign = 'center'; ctx.fillText(n.name, n.x, n.y + r + 12); } ``` The Protagonist dominates the center -- highest degree, most co-occurrences, pulled toward everyone. The Villain's group (red/pink) clusters separately but connects to the Protagonist through direct encounters and through the Spy who bridges both worlds. The Friend group (green) orbits the Rival, connected to the hero side but with their own internal density. The Elder and Narrator drift near the hero cluster but at the periphery -- they appear less frequently. This is what network visualization does that no other technique can: it reveals *relational structure*. Not "how much" or "when" or "where" -- but "who connects to whom, and how tightly." The social architecture of a novel, visible in a single image. If you fed a real book through a paragraph co-occurrence counter (Project Gutenberg has plenty of free texts), the network would reveal the book's social structure in a way that reading the text cover-to-cover doesn't make explicit. ## Radial layout: hierarchy as rings Not all graphs are flat peer-to-peer networks. Some have hierarchy -- a root node with children, grandchildren, great-grandchildren. Organizational charts, file system trees, taxonomy classifications. A radial tree layout places the root at the center and arranges each level of the hierarchy as a concentric ring. ```javascript const canvas = document.createElement('canvas'); canvas.width = 700; canvas.height = 700; document.body.appendChild(canvas); const ctx = canvas.getContext('2d'); // build a tree: root has 4 children, each has 2-4 grandchildren const tree = { id: 0, children: [] }; let nextId = 1; for (let i = 0; i < 4; i++) { const child = { id: nextId++, children: [] }; const numGrand = 2 + Math.floor(Math.random() * 3); for (let j = 0; j < numGrand; j++) { const grand = { id: nextId++, children: [] }; // some grandchildren have leaves if (Math.random() < 0.5) { const numLeaves = 1 + Math.floor(Math.random() * 3); for (let k = 0; k < numLeaves; k++) { grand.children.push({ id: nextId++, children: [] }); } } child.children.push(grand); } tree.children.push(child); } ctx.fillStyle = '#0a0a1a'; ctx.fillRect(0, 0, 700, 700); const cx = 350; const cy = 350; function drawRadialTree(node, startAngle, endAngle, depth) { const radius = depth * 80; const angle = (startAngle + endAngle) / 2; const x = cx + Math.cos(angle) * radius; const y = cy + Math.sin(angle) * radius; // draw edge to parent (except root) if (depth > 0) { const parentAngle = angle; const parentR = (depth - 1) * 80; const px = cx + Math.cos(parentAngle) * parentR; const py = cy + Math.sin(parentAngle) * parentR; ctx.beginPath(); ctx.moveTo(px, py); ctx.lineTo(x, y); ctx.strokeStyle = `rgba(80, 120, 180, ${0.5 - depth * 0.1})`; ctx.lineWidth = 3 - depth * 0.5; ctx.stroke(); } // draw node const r = depth === 0 ? 8 : 4; const hue = 200 + depth * 40; ctx.beginPath(); ctx.arc(x, y, r, 0, Math.PI * 2); ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.7)`; ctx.fill(); // recurse for children if (node.children.length > 0) { const sliceSize = (endAngle - startAngle) / node.children.length; for (let i = 0; i < node.children.length; i++) { const childStart = startAngle + i * sliceSize; const childEnd = childStart + sliceSize; drawRadialTree(node.children[i], childStart, childEnd, depth + 1); } } } drawRadialTree(tree, 0, Math.PI * 2, 0); ``` The root sits at the center. Four branches radiate outward, each claiming a quarter of the circle (roughly). Grandchildren subdivide their parent's angular range. Leaves sit at the outermost ring. The radial layout uses space efficiently -- linear tree layouts waste horizontal space because deep trees get very wide, but the radial tree wraps everything into a compact circle. The color shifts from blue (root) to warmer tones (leaves), giving you a visual depth cue. Edge thickness decreases with depth too -- the trunk is thick, the branches thin, matching the visual metaphor of an actual tree. ## What's coming We've got geography (ep083), time (ep084), and now networks (this episode) -- three fundamentally different data structures, each with its own visual language. But most real-world data isn't neatly geographic, temporal, or relational. It's messy text -- articles, tweets, logs, books. Extracting visual patterns from text requires a different set of techniques: word frequency, sentiment analysis, linguistic structure. And text happens to be one of the richest sources of creative raw material when you know how to parse it. ## 't Komt erop neer... - Networks have two elements: nodes (entities) and edges (connections). Social networks, citation graphs, hyperlinks, character co-occurrence, function call trees -- most complex systems are graphs. The visual language is node-link diagrams (circles and lines), but layout is the hard problem: where you place nodes determines whether the picture is readable or a hairball - Force-directed layout treats edges as springs and node pairs as repelling charges, then simulates physics until equilibrium. Connected nodes cluster together, unconnected nodes drift apart. Three forces: repulsion (all pairs, inverse square), spring attraction (edges only, Hooke's law), and centering (weak pull to canvas center). Damping drains kinetic energy so the system converges - Visual encoding makes nodes meaningful: size from degree (area-proportional using `Math.sqrt`), color from category or importance, edge thickness from weight. Without encoding, all nodes look the same and the graph hides its information - Community detection assigns colors to clusters of densely connected nodes. Label propagation is simple: each node adopts the most common label among its neighbors, iterated until convergence. Golden angle spacing (137 degrees between hues) prevents adjacent community indices from getting similar colors - Adjacency matrices represent edges as colored cells in a grid (row i, column j filled if there's an edge). They never have crossing edges and scale to dense graphs where node-link diagrams become hairballs. But node ordering matters critically -- random order hides the block structure, sorted order reveals it - Arc diagrams place nodes on a horizontal line with edges as arcs above. Arc height shows the distance between connected nodes in the linear ordering. Works beautifully for sequential data -- characters in a story, functions in source code -- where the linear order carries meaning - Interactive hover-and-highlight is essential for networks above ~30 nodes. Highlight one node's edges and neighbors while fading everything else. The ego-network pops out of the complexity. Same graph looks different from every node's perspective - Edge bundling reduces visual clutter by routing nearby edges along shared curves. Bend each edge's bezier control point toward a common attractor (graph centroid). Bundled edges at low opacity reveal major flow patterns while isolated edges fade - D3-force is the production standard for force layout. Import just the force module, let it compute positions, and render with canvas. Adaptive cooling converges faster than constant damping. Separation of layout engine from rendering keeps your visual style flexible - Radial tree layout places the root at center with each hierarchy level as a concentric ring. Children subdivide their parent's angular range. Compact and space-efficient compared to linear tree layouts. Color and line thickness shifting with depth reinforces the hierarchy visually Sallukes! Thanks for reading. X @femdev
titleLearn Creative Coding (#85) - Network and Graph Visualization
authorfemdev
permlinklearn-creative-coding-85-network-and-graph-visualization
json metadata{"tags": ["stem", "stemsocial", "steemstem", "programming", "creativecoding"], "app": "hiveblog/0.1", "format": "markdown", "image": ["https://images.hive.blog/DQmZCVibociQsR6XsLDTrnzNBjz47h1z3ocJGFZgerPf7xM/cc-banner-red.png"]}
parent author
parent permlinkhive-196387
Transaction InfoBlock #107005628/Trx 39157976d1a0075edc574ccddecafea4ea97491f
View Raw JSON Data
{
  "op": [
    "comment",
    {
      "body": "# Learn Creative Coding (#85) - Network and Graph Visualization\n\n![cc-banner](https://images.hive.blog/DQmZCVibociQsR6XsLDTrnzNBjz47h1z3ocJGFZgerPf7xM/cc-banner-red.png)\n\nLast episode we visualized time -- timelines, spirals, calendar heatmaps, clock diagrams, streamgraphs, animated playback, and those dense hourly-by-daily fingerprint grids that cram 8,760 data points into a single image. Time is the most common data dimension and the way you lay it out on canvas determines which patterns appear. Straight lines ask \"what happened?\" Spirals ask \"what repeats?\" The representation IS the question.\n\nBut not all data is about when or where. Some data is about *who connects to whom*. Social networks, hyperlink graphs, citation networks, character co-occurrence in novels, function call trees in codebases, biological pathways, trade routes between countries. The underlying structure isn't a sequence or a grid -- it's a *graph*. Nodes and edges. Entities and relationships. And the visual language for rendering them is completely different from anything we've built so far.\n\nThis episode is about network visualization. We'll build force-directed layouts from scratch (springs and charges, simulated until equilibrium), encode node importance through size and color, detect visual communities, try alternative layouts like adjacency matrices and arc diagrams, and add the interactivity that makes dense networks actually explorable. We used physics simulation back in episode 18 for springs and flocking -- now those same forces arrange data instead of particles.\n\n## What's a graph, visually?\n\nA graph has two things: nodes (the entities) and edges (the connections between them). A social network: nodes are people, edges are friendships. A citation network: nodes are papers, edges are references. A hyperlink graph: nodes are web pages, edges are links. The internet itself is a graph. Your brain is a graph. Most complex systems are.\n\nVisually, the classic representation is a node-link diagram: circles for nodes, lines between them for edges. Simple in concept. Nightmarishly complex in practice when you have more then about 20 nodes, because the layout problem -- where do you *put* each node so the picture is readable? -- is genuinely hard. There's no single right answer. The same graph can look like a mess or a revelation depending on where you position the nodes.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 600;\ncanvas.height = 600;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// a small graph: 8 nodes, some edges\nconst nodes = [\n  { id: 0, x: 150, y: 150 },\n  { id: 1, x: 300, y: 100 },\n  { id: 2, x: 450, y: 150 },\n  { id: 3, x: 100, y: 350 },\n  { id: 4, x: 300, y: 300 },\n  { id: 5, x: 500, y: 350 },\n  { id: 6, x: 200, y: 480 },\n  { id: 7, x: 400, y: 480 }\n];\n\nconst edges = [\n  [0, 1], [1, 2], [0, 4], [1, 4],\n  [2, 5], [3, 4], [3, 6], [4, 5],\n  [4, 7], [5, 7], [6, 7]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 600, 600);\n\n// draw edges\nfor (const [a, b] of edges) {\n  ctx.beginPath();\n  ctx.moveTo(nodes[a].x, nodes[a].y);\n  ctx.lineTo(nodes[b].x, nodes[b].y);\n  ctx.strokeStyle = 'rgba(100, 140, 200, 0.3)';\n  ctx.lineWidth = 1.5;\n  ctx.stroke();\n}\n\n// draw nodes\nfor (const node of nodes) {\n  ctx.beginPath();\n  ctx.arc(node.x, node.y, 8, 0, Math.PI * 2);\n  ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';\n  ctx.fill();\n  ctx.strokeStyle = 'rgba(160, 200, 255, 0.4)';\n  ctx.lineWidth = 1;\n  ctx.stroke();\n}\n```\n\nEight nodes, eleven edges. I placed them by hand at positions that look reasonable. But that's the problem -- I *chose* those positions. They don't come from the data. If you add a 9th node, where does it go? If you have 200 nodes? Manual placement doesn't scale. We need an algorithm that figures out good positions automatically.\n\n## Force-directed layout: physics as design\n\nThis is the big one. The idea: treat each edge as a spring (it pulls connected nodes together) and treat each pair of nodes as repelling charges (they push apart, like magnets with the same polarity). Then simulate the physics. Connected nodes get pulled close. Unconnected nodes get pushed away. After enough iterations, the system reaches equilibrium -- a layout where the forces balance out. Clusters of densely connected nodes end up near each other, and loosely connected parts drift apart.\n\nIt's the same spring physics we built in episode 18, but applied to layout instead of animation.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 700;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// generate a random graph\nconst numNodes = 30;\nconst nodes = [];\nfor (let i = 0; i < numNodes; i++) {\n  nodes.push({\n    x: 200 + Math.random() * 300,\n    y: 200 + Math.random() * 300,\n    vx: 0,\n    vy: 0\n  });\n}\n\n// random edges -- each node connects to 2-4 others\nconst edges = [];\nfor (let i = 0; i < numNodes; i++) {\n  const numEdges = 2 + Math.floor(Math.random() * 3);\n  for (let e = 0; e < numEdges; e++) {\n    const j = Math.floor(Math.random() * numNodes);\n    if (j !== i) {\n      edges.push([i, j]);\n    }\n  }\n}\n\nfunction simulate() {\n  const repulsion = 5000;\n  const springStrength = 0.01;\n  const springLength = 80;\n  const damping = 0.85;\n\n  // repulsion between all node pairs\n  for (let i = 0; i < numNodes; i++) {\n    for (let j = i + 1; j < numNodes; j++) {\n      const dx = nodes[j].x - nodes[i].x;\n      const dy = nodes[j].y - nodes[i].y;\n      const dist = Math.sqrt(dx * dx + dy * dy) + 0.1;\n      const force = repulsion / (dist * dist);\n      const fx = (dx / dist) * force;\n      const fy = (dy / dist) * force;\n\n      nodes[i].vx -= fx;\n      nodes[i].vy -= fy;\n      nodes[j].vx += fx;\n      nodes[j].vy += fy;\n    }\n  }\n\n  // spring attraction along edges\n  for (const [a, b] of edges) {\n    const dx = nodes[b].x - nodes[a].x;\n    const dy = nodes[b].y - nodes[a].y;\n    const dist = Math.sqrt(dx * dx + dy * dy) + 0.1;\n    const displacement = dist - springLength;\n    const fx = (dx / dist) * displacement * springStrength;\n    const fy = (dy / dist) * displacement * springStrength;\n\n    nodes[a].vx += fx;\n    nodes[a].vy += fy;\n    nodes[b].vx -= fx;\n    nodes[b].vy -= fy;\n  }\n\n  // centering force (gentle pull toward canvas center)\n  for (const node of nodes) {\n    node.vx += (350 - node.x) * 0.001;\n    node.vy += (350 - node.y) * 0.001;\n  }\n\n  // update positions with damping\n  for (const node of nodes) {\n    node.vx *= damping;\n    node.vy *= damping;\n    node.x += node.vx;\n    node.y += node.vy;\n  }\n}\n\nfunction draw() {\n  ctx.fillStyle = '#0a0a1a';\n  ctx.fillRect(0, 0, 700, 700);\n\n  // edges\n  for (const [a, b] of edges) {\n    ctx.beginPath();\n    ctx.moveTo(nodes[a].x, nodes[a].y);\n    ctx.lineTo(nodes[b].x, nodes[b].y);\n    ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';\n    ctx.lineWidth = 1;\n    ctx.stroke();\n  }\n\n  // nodes\n  for (const node of nodes) {\n    ctx.beginPath();\n    ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);\n    ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';\n    ctx.fill();\n  }\n\n  simulate();\n  requestAnimationFrame(draw);\n}\n\ndraw();\n```\n\nThirty nodes, random edges. They start in a clump and then -- over a few seconds -- they sort themselves out. Connected groups drift together. Bridges between groups stretch into visible corridors. Isolated nodes float to the edges. The layout *emerges* from the physics. You didn't design it. The forces did.\n\nThe three forces at work:\n\n1. **Repulsion** (all pairs): Coulomb's law in reverse. Every node pushes every other node away. The force drops off with the square of the distance -- close nodes push hard, far nodes barely affect each other. This prevents everything from collapsing into a single point.\n\n2. **Spring attraction** (edges only): Hooke's law. Connected nodes are pulled toward a target distance (`springLength`). If they're farther apart, the spring pulls them together. If they're closer, it pushes them apart (yes, springs do that too). This keeps connected nodes near each other.\n\n3. **Centering** (all nodes): A weak pull toward the center of the canvas. Without this, the whole graph drifts off-screen because repulsion pushes everything outward with no counterweight.\n\nThe `damping` factor (0.85) acts like friction. Each frame, velocities are reduced by 15%. Without damping, the system oscillates forever -- nodes bounce back and forth and never settle. With damping, the kinetic energy drains away and the system converges to a stable layout.\n\n## Visual encoding: making nodes meaningful\n\nA graph where every node looks the same is a graph that's hiding information. In most real networks, nodes have properties: importance, category, size of following, number of connections. The visual encoding of those properties turns a structural diagram into a data visualization.\n\nThe most important node property is **degree** -- how many edges connect to it. High-degree nodes are hubs. They hold the network together. Low-degree nodes are peripheral. You can compute degree just by counting edges per node.\n\n```javascript\n// compute degree for each node\nconst degree = new Array(numNodes).fill(0);\nfor (const [a, b] of edges) {\n  degree[a]++;\n  degree[b]++;\n}\n\nconst maxDeg = Math.max(...degree);\n\n// draw nodes with size and color from degree\nfor (let i = 0; i < numNodes; i++) {\n  const node = nodes[i];\n  const d = degree[i];\n\n  // area-proportional sizing (ep082 lesson)\n  const area = (d / maxDeg) * 800 + 50;\n  const r = Math.sqrt(area / Math.PI);\n\n  // color: low degree = cool blue, high degree = warm amber\n  const hue = 220 - (d / maxDeg) * 180;\n  const lightness = 30 + (d / maxDeg) * 25;\n\n  ctx.beginPath();\n  ctx.arc(node.x, node.y, r, 0, Math.PI * 2);\n  ctx.fillStyle = `hsla(${hue}, 55%, ${lightness}%, 0.7)`;\n  ctx.fill();\n}\n```\n\nNow the hub nodes are large and warm-colored. Peripheral nodes are small and blue. You can immediately see which nodes hold the network together without counting edges manually. The area-proportional sizing we learned in episode 82 matters here too -- mapping degree to radius instead of area would exaggerate the visual importance of hubs. `Math.sqrt(area / Math.PI)` keeps it honest.\n\nEdge thickness can carry meaning too. If your edges have weights (frequency of communication, number of shared connections, traffic volume), thicker edges mean stronger relationships:\n\n```javascript\n// draw edges with thickness from weight\nfor (const edge of weightedEdges) {\n  ctx.beginPath();\n  ctx.moveTo(nodes[edge.source].x, nodes[edge.source].y);\n  ctx.lineTo(nodes[edge.target].x, nodes[edge.target].y);\n\n  const thickness = 0.5 + (edge.weight / maxWeight) * 4;\n  ctx.strokeStyle = `rgba(80, 120, 180, ${0.1 + (edge.weight / maxWeight) * 0.4})`;\n  ctx.lineWidth = thickness;\n  ctx.stroke();\n}\n```\n\nThick bright edges are strong connections. Thin faint edges are weak ties. The network's backbone becomes visible -- the thick edges form the structural core, and the thin ones are the periphery.\n\n## Community detection by color\n\nReal networks have communities -- groups of nodes that are more connected to each other than to the rest of the network. Social cliques, academic departments, music genre clusters. Visually, communities should have distinct colors so you can see the group structure at a glance.\n\nA simple (not academically rigorous but visually effective) approach: assign each node to the community of its most-connected neighbor. Repeat a few times until it converges. It's a form of label propagation.\n\n```javascript\n// simple community detection: label propagation\nconst community = [];\nfor (let i = 0; i < numNodes; i++) {\n  community[i] = i; // each node starts as its own community\n}\n\n// build adjacency list\nconst neighbors = Array.from({ length: numNodes }, () => []);\nfor (const [a, b] of edges) {\n  neighbors[a].push(b);\n  neighbors[b].push(a);\n}\n\n// iterate: each node adopts the most common label among neighbors\nfor (let iter = 0; iter < 10; iter++) {\n  for (let i = 0; i < numNodes; i++) {\n    if (neighbors[i].length === 0) continue;\n\n    const counts = {};\n    for (const n of neighbors[i]) {\n      const label = community[n];\n      counts[label] = (counts[label] || 0) + 1;\n    }\n\n    // find the most frequent label\n    let bestLabel = community[i];\n    let bestCount = 0;\n    for (const [label, count] of Object.entries(counts)) {\n      if (count > bestCount) {\n        bestCount = count;\n        bestLabel = parseInt(label);\n      }\n    }\n    community[i] = bestLabel;\n  }\n}\n\n// map community labels to colors\nconst uniqueCommunities = [...new Set(community)];\nconst communityColors = {};\nfor (let i = 0; i < uniqueCommunities.length; i++) {\n  communityColors[uniqueCommunities[i]] = (i * 137) % 360; // golden angle for good spread\n}\n\n// draw with community colors\nfor (let i = 0; i < numNodes; i++) {\n  const node = nodes[i];\n  const hue = communityColors[community[i]];\n\n  ctx.beginPath();\n  ctx.arc(node.x, node.y, 6, 0, Math.PI * 2);\n  ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;\n  ctx.fill();\n}\n```\n\nAfter a few iterations, connected clusters converge on the same label. The golden angle spacing (137 degrees between successive hues) ensures that nearby community indices don't get similar colors -- same trick that sunflowers use to pack seeds efficiently. The result: clusters of same-colored nodes with differently-colored bridges between them. The community structure that's implicit in the edge list becomes explicit in the color map.\n\n## The adjacency matrix: when node-link fails\n\nNode-link diagrams break down when graphs are dense. Above ~100 nodes with many edges, the drawing becomes a hairball -- a tangle of crossing lines where no structure is visible. The adjacency matrix is the alternative.\n\nAn adjacency matrix has one row and one column per node. The cell at row i, column j is filled if there's an edge between nodes i and j. It's a 2D grid representation of the same information as the node-link diagram, but it never has crossing edges because there are no edges -- just cells.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 500;\ncanvas.height = 500;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\nconst n = 25;\n// generate a clustered adjacency matrix\nconst matrix = Array.from({ length: n }, () => new Array(n).fill(0));\n\n// three clusters: 0-8, 9-16, 17-24\nfunction sameCluster(i, j) {\n  return Math.floor(i / 9) === Math.floor(j / 9);\n}\n\nfor (let i = 0; i < n; i++) {\n  for (let j = i + 1; j < n; j++) {\n    const prob = sameCluster(i, j) ? 0.6 : 0.05;\n    if (Math.random() < prob) {\n      matrix[i][j] = 1;\n      matrix[j][i] = 1;\n    }\n  }\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 500, 500);\n\nconst cellSize = 16;\nconst offset = 40;\n\nfor (let i = 0; i < n; i++) {\n  for (let j = 0; j < n; j++) {\n    const x = offset + j * cellSize;\n    const y = offset + i * cellSize;\n\n    if (matrix[i][j]) {\n      const cluster = Math.floor(i / 9);\n      const hues = [200, 130, 350];\n      ctx.fillStyle = `hsla(${hues[cluster]}, 50%, 45%, 0.7)`;\n    } else {\n      ctx.fillStyle = 'rgba(25, 30, 40, 0.5)';\n    }\n\n    ctx.fillRect(x, y, cellSize - 1, cellSize - 1);\n  }\n}\n```\n\nThe three clusters show up as dense colored blocks along the diagonal. Connections between clusters are sparse dots scattered off-diagonal. The block structure is unmistakable -- you can see exactly how many inter-cluster connections exist by counting the off-diagonal dots. In a node-link diagram of the same graph, those inter-cluster edges would be tangled lines crossing through the clusters and the block structure would be invisible.\n\nAdjacency matrices have a crucial property: the ordering of rows and columns matters enormously. If you shuffle the node order randomly, the blocks disappear -- the same connections become scattered cells with no visible pattern. A good adjacency matrix requires sorting nodes so that communities end up adjacent. That ordering IS the analysis -- getting it right reveals the structure, getting it wrong hides it.\n\n## Arc diagram: the literary layout\n\nAn arc diagram places all nodes along a horizontal line and draws edges as arcs above it. The arc height is proportional to the distance between connected nodes in the linear ordering. Nearby connections are short arcs. Long-range connections are tall arcs that reach across the diagram.\n\nThis layout works beautifully for sequential data. Characters in a book: who interacts with whom, and how far apart are they in the narrative? Function calls in a program: which functions call which other functions, and how far apart are they in the source file? Any data with a natural linear ordering benefits from the arc layout.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 900;\ncanvas.height = 350;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// 15 \"characters\" in a story, with interactions\nconst characters = [\n  'Alice', 'Bob', 'Carol', 'Dan', 'Eve',\n  'Frank', 'Grace', 'Hank', 'Iris', 'Jack',\n  'Kate', 'Leo', 'Mia', 'Nate', 'Olive'\n];\n\nconst interactions = [\n  [0, 1, 8],  [0, 2, 3],  [1, 3, 5],  [2, 3, 6],\n  [3, 5, 2],  [4, 6, 7],  [5, 7, 4],  [6, 8, 3],\n  [7, 9, 5],  [8, 10, 2], [9, 11, 6], [10, 12, 4],\n  [11, 13, 3], [12, 14, 5], [0, 14, 1], [3, 10, 2],\n  [1, 7, 3],  [5, 12, 2]\n  // [source, target, weight]\n];\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 900, 350);\n\nconst y = 280;\nconst startX = 50;\nconst spacing = 55;\n\n// draw arcs\nfor (const [a, b, w] of interactions) {\n  const x1 = startX + a * spacing;\n  const x2 = startX + b * spacing;\n  const midX = (x1 + x2) / 2;\n  const arcHeight = Math.abs(b - a) * 18;\n\n  ctx.beginPath();\n  ctx.moveTo(x1, y);\n  ctx.quadraticCurveTo(midX, y - arcHeight, x2, y);\n  ctx.strokeStyle = `rgba(140, 180, 255, ${0.15 + (w / 8) * 0.4})`;\n  ctx.lineWidth = 0.5 + (w / 8) * 3;\n  ctx.stroke();\n}\n\n// draw nodes\nfor (let i = 0; i < characters.length; i++) {\n  const x = startX + i * spacing;\n\n  ctx.beginPath();\n  ctx.arc(x, y, 4, 0, Math.PI * 2);\n  ctx.fillStyle = 'rgba(120, 180, 255, 0.8)';\n  ctx.fill();\n\n  ctx.save();\n  ctx.translate(x, y + 12);\n  ctx.rotate(-Math.PI / 4);\n  ctx.fillStyle = 'rgba(160, 170, 190, 0.5)';\n  ctx.font = '9px monospace';\n  ctx.textAlign = 'right';\n  ctx.fillText(characters[i], 0, 0);\n  ctx.restore();\n}\n```\n\nThe long arc from Alice (position 0) to Olive (position 14) towers over the short arcs between adjacent characters. Edge thickness and opacity encode interaction frequency -- thicker arcs mean more interaction. You can see the narrative structure: most interactions happen between nearby characters (short arcs, dense connections), with a few long-range relationships bridging distant parts of the story.\n\nArc diagrams are one of my favourite graph layouts for creative coding because they have strong visual rhythm. The nested arcs create a kind of topographic contour that's inherently beautiful even before you think about what the data means. :-)\n\n## Interactive exploration: hover and highlight\n\nNetworks are too complex for purely static views. With 30+ nodes, the viewer needs to be able to focus on one node at a time and see its neighborhood highlighted while everything else fades. This is where mouse interaction becomes essential.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 700;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// reuse force-directed layout from earlier\n// (assume nodes[] and edges[] are already computed and settled)\n\nlet hoveredNode = -1;\n\ncanvas.addEventListener('mousemove', function(e) {\n  const rect = canvas.getBoundingClientRect();\n  const mx = e.clientX - rect.left;\n  const my = e.clientY - rect.top;\n\n  hoveredNode = -1;\n  for (let i = 0; i < nodes.length; i++) {\n    const dx = mx - nodes[i].x;\n    const dy = my - nodes[i].y;\n    if (dx * dx + dy * dy < 15 * 15) {\n      hoveredNode = i;\n      break;\n    }\n  }\n});\n\nfunction drawInteractive() {\n  ctx.fillStyle = '#0a0a1a';\n  ctx.fillRect(0, 0, 700, 700);\n\n  // find neighbors of hovered node\n  const highlighted = new Set();\n  const highlightedEdges = new Set();\n  if (hoveredNode >= 0) {\n    highlighted.add(hoveredNode);\n    for (let e = 0; e < edges.length; e++) {\n      const [a, b] = edges[e];\n      if (a === hoveredNode || b === hoveredNode) {\n        highlighted.add(a);\n        highlighted.add(b);\n        highlightedEdges.add(e);\n      }\n    }\n  }\n\n  // draw edges\n  for (let e = 0; e < edges.length; e++) {\n    const [a, b] = edges[e];\n    ctx.beginPath();\n    ctx.moveTo(nodes[a].x, nodes[a].y);\n    ctx.lineTo(nodes[b].x, nodes[b].y);\n\n    if (hoveredNode >= 0) {\n      ctx.strokeStyle = highlightedEdges.has(e)\n        ? 'rgba(120, 180, 255, 0.6)'\n        : 'rgba(60, 70, 90, 0.1)';\n      ctx.lineWidth = highlightedEdges.has(e) ? 2 : 0.5;\n    } else {\n      ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';\n      ctx.lineWidth = 1;\n    }\n    ctx.stroke();\n  }\n\n  // draw nodes\n  for (let i = 0; i < nodes.length; i++) {\n    const isHighlighted = hoveredNode < 0 || highlighted.has(i);\n\n    ctx.beginPath();\n    ctx.arc(nodes[i].x, nodes[i].y, isHighlighted ? 7 : 5, 0, Math.PI * 2);\n    ctx.fillStyle = isHighlighted\n      ? 'rgba(120, 180, 255, 0.8)'\n      : 'rgba(50, 60, 80, 0.3)';\n    ctx.fill();\n  }\n\n  requestAnimationFrame(drawInteractive);\n}\n\ndrawInteractive();\n```\n\nWhen you hover over a node, its edges light up and its direct neighbors stay visible while everything else fades to near-invisibility. The ego-network (a node plus its connections) pops out of the complexity. Move to a different node and a completely different neighborhood emerges. The same graph looks different from every node's perspective -- which is actually a deep truth about networks. Your experience of a social network depends entirely on where *you* sit in it.\n\n## Edge bundling: taming the hairball\n\nWhen a graph has hundreds of edges, even a good force-directed layout produces visual clutter. Edges cross each other in every direction and the structural patterns disappear into spaghetti. Edge bundling is a technique that routes nearby edges along shared paths, like cables bundled into conduits. Parallel edges merge into streams, then diverge to their individual endpoints.\n\nThe simplest approach: for each edge, instead of drawing a straight line from source to target, draw a bezier curve that bends toward the midpoint of the canvas (or toward the centroid of the graph). Edges with nearby endpoints share similar curves, creating the illusion of bundles.\n\n```javascript\n// simple edge bundling: curve edges toward center\nconst cx = 350;\nconst cy = 350;\nconst bundleStrength = 0.4;\n\nfor (const [a, b] of edges) {\n  const x1 = nodes[a].x;\n  const y1 = nodes[a].y;\n  const x2 = nodes[b].x;\n  const y2 = nodes[b].y;\n\n  // control point pulled toward center\n  const midX = (x1 + x2) / 2;\n  const midY = (y1 + y2) / 2;\n  const ctrlX = midX + (cx - midX) * bundleStrength;\n  const ctrlY = midY + (cy - midY) * bundleStrength;\n\n  ctx.beginPath();\n  ctx.moveTo(x1, y1);\n  ctx.quadraticCurveTo(ctrlX, ctrlY, x2, y2);\n  ctx.strokeStyle = 'rgba(80, 140, 220, 0.08)';\n  ctx.lineWidth = 1;\n  ctx.stroke();\n}\n```\n\nThe `bundleStrength` parameter controls how aggressively edges are pulled toward center. At 0 you get straight lines (no bundling). At 1 all edges pass through the center (maximally bundled, but you lose endpoint information). 0.3-0.5 is usually a good range -- enough bundling to reveal flow patterns, but the individual edge endpoints are still distinguisable.\n\nWith many edges at low opacity, the bundles emerge naturally from the overlapping curves. Dense bundles -- where many edges between two clusters merge into a thick stream -- glow brightly. Isolated edges stay faint. The major highways of the network become visible while the side streets fade into the background.\n\n## Using d3-force for production layouts\n\nBuilding force simulation from scratch is educational (we just did it), but for production work you'd use d3-force. It's the gold standard force-directed layout engine, and you can import just the force module without the rest of D3. It handles all the physics, adaptive cooling, collision detection, and configurable force parameters.\n\n```javascript\n// d3-force handles layout, we handle rendering with canvas\n// import { forceSimulation, forceLink, forceManyBody, forceCenter } from 'd3-force';\n\nconst simulation = d3.forceSimulation(nodes)\n  .force('charge', d3.forceManyBody().strength(-200))\n  .force('link', d3.forceLink(edges).distance(60))\n  .force('center', d3.forceCenter(350, 350))\n  .on('tick', draw);\n\nfunction draw() {\n  ctx.fillStyle = '#0a0a1a';\n  ctx.fillRect(0, 0, 700, 700);\n\n  // d3 updates node.x and node.y automatically\n  for (const edge of edges) {\n    ctx.beginPath();\n    ctx.moveTo(edge.source.x, edge.source.y);\n    ctx.lineTo(edge.target.x, edge.target.y);\n    ctx.strokeStyle = 'rgba(80, 120, 180, 0.2)';\n    ctx.lineWidth = 1;\n    ctx.stroke();\n  }\n\n  for (const node of nodes) {\n    ctx.beginPath();\n    ctx.arc(node.x, node.y, 5, 0, Math.PI * 2);\n    ctx.fillStyle = 'rgba(120, 180, 255, 0.7)';\n    ctx.fill();\n  }\n}\n```\n\nThe key difference from our DIY version: d3-force uses adaptive cooling. The simulation starts \"hot\" (large movements each tick) and gradually cools down (smaller and smaller movements) until it stops. Our manual version used constant damping, which works but takes longer to settle. D3's cooling schedule converges faster and produces smoother results.\n\nYou don't need to use D3 for rendering -- that's the whole point. D3 calculates the positions, and you render with canvas (or p5, or WebGL, or whatever). Separation of layout from rendering. The layout engine is a tool, the visual style is yours.\n\n## Creative exercise: character co-occurrence network\n\nAllez, time to build something real. A character co-occurrence network from a novel. Two characters are connected if they appear in the same paragraph. The more paragraphs they share, the stronger the connection. Node size from total appearances, edge weight from co-occurrence count. Force-directed layout, colored by community.\n\nWe'll use a simplified dataset -- manually defining character appearances would take forever for a full novel, so we'll simulate the pattern with a fake \"book\" that has the right statistical structure.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 800;\ncanvas.height = 800;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// simulated book characters with appearance counts and co-occurrence\nconst characters = [\n  { name: 'Protagonist', appearances: 180, group: 0 },\n  { name: 'Mentor', appearances: 95, group: 0 },\n  { name: 'Sidekick', appearances: 120, group: 0 },\n  { name: 'Villain', appearances: 85, group: 1 },\n  { name: 'Henchman1', appearances: 40, group: 1 },\n  { name: 'Henchman2', appearances: 35, group: 1 },\n  { name: 'Love Interest', appearances: 75, group: 0 },\n  { name: 'Rival', appearances: 60, group: 2 },\n  { name: 'Friend1', appearances: 45, group: 2 },\n  { name: 'Friend2', appearances: 50, group: 2 },\n  { name: 'Elder', appearances: 30, group: 0 },\n  { name: 'Spy', appearances: 55, group: 1 },\n  { name: 'Narrator', appearances: 70, group: 0 },\n  { name: 'Merchant', appearances: 25, group: 2 },\n  { name: 'Guard', appearances: 20, group: 1 }\n];\n\n// co-occurrence edges (source, target, shared paragraphs)\nconst cooccurrence = [\n  [0, 1, 45], [0, 2, 70], [0, 3, 30], [0, 6, 40],\n  [0, 7, 25], [0, 12, 35], [1, 2, 20], [1, 10, 15],\n  [1, 12, 18], [2, 6, 15], [2, 7, 12], [3, 4, 30],\n  [3, 5, 25], [3, 11, 22], [4, 5, 18], [4, 14, 12],\n  [5, 14, 10], [6, 12, 8], [7, 8, 20], [7, 9, 18],\n  [8, 9, 25], [8, 13, 10], [9, 13, 8], [10, 12, 6],\n  [11, 3, 15], [11, 14, 8], [0, 11, 10], [2, 3, 8]\n];\n\n// initialize node positions\nconst nodes = characters.map((c, i) => ({\n  x: 400 + (Math.random() - 0.5) * 200,\n  y: 400 + (Math.random() - 0.5) * 200,\n  vx: 0, vy: 0,\n  ...c\n}));\n\n// run force simulation for 200 steps\nfor (let step = 0; step < 200; step++) {\n  const cooling = 1.0 - step / 200;\n\n  // repulsion\n  for (let i = 0; i < nodes.length; i++) {\n    for (let j = i + 1; j < nodes.length; j++) {\n      const dx = nodes[j].x - nodes[i].x;\n      const dy = nodes[j].y - nodes[i].y;\n      const dist = Math.sqrt(dx * dx + dy * dy) + 1;\n      const force = 4000 / (dist * dist);\n      const fx = (dx / dist) * force * cooling;\n      const fy = (dy / dist) * force * cooling;\n      nodes[i].vx -= fx; nodes[i].vy -= fy;\n      nodes[j].vx += fx; nodes[j].vy += fy;\n    }\n  }\n\n  // spring attraction\n  for (const [a, b, w] of cooccurrence) {\n    const dx = nodes[b].x - nodes[a].x;\n    const dy = nodes[b].y - nodes[a].y;\n    const dist = Math.sqrt(dx * dx + dy * dy) + 1;\n    const strength = 0.005 + (w / 70) * 0.01;\n    const target = 70;\n    const disp = dist - target;\n    const fx = (dx / dist) * disp * strength * cooling;\n    const fy = (dy / dist) * disp * strength * cooling;\n    nodes[a].vx += fx; nodes[a].vy += fy;\n    nodes[b].vx -= fx; nodes[b].vy -= fy;\n  }\n\n  // center + damping\n  for (const node of nodes) {\n    node.vx += (400 - node.x) * 0.002;\n    node.vy += (400 - node.y) * 0.002;\n    node.vx *= 0.8;\n    node.vy *= 0.8;\n    node.x += node.vx;\n    node.y += node.vy;\n  }\n}\n\n// draw\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 800, 800);\n\nconst groupHues = [200, 350, 130];\nconst maxApp = Math.max(...characters.map(c => c.appearances));\nconst maxW = Math.max(...cooccurrence.map(e => e[2]));\n\n// edges\nfor (const [a, b, w] of cooccurrence) {\n  ctx.beginPath();\n  ctx.moveTo(nodes[a].x, nodes[a].y);\n  ctx.lineTo(nodes[b].x, nodes[b].y);\n  ctx.strokeStyle = `rgba(100, 130, 180, ${0.05 + (w / maxW) * 0.3})`;\n  ctx.lineWidth = 0.5 + (w / maxW) * 3;\n  ctx.stroke();\n}\n\n// nodes\nfor (let i = 0; i < nodes.length; i++) {\n  const n = nodes[i];\n  const area = (n.appearances / maxApp) * 1200 + 80;\n  const r = Math.sqrt(area / Math.PI);\n  const hue = groupHues[n.group];\n\n  // glow\n  ctx.beginPath();\n  ctx.arc(n.x, n.y, r + 4, 0, Math.PI * 2);\n  ctx.fillStyle = `hsla(${hue}, 50%, 50%, 0.12)`;\n  ctx.fill();\n\n  // core\n  ctx.beginPath();\n  ctx.arc(n.x, n.y, r, 0, Math.PI * 2);\n  ctx.fillStyle = `hsla(${hue}, 55%, 50%, 0.7)`;\n  ctx.fill();\n\n  // label\n  ctx.fillStyle = 'rgba(180, 190, 210, 0.5)';\n  ctx.font = '9px monospace';\n  ctx.textAlign = 'center';\n  ctx.fillText(n.name, n.x, n.y + r + 12);\n}\n```\n\nThe Protagonist dominates the center -- highest degree, most co-occurrences, pulled toward everyone. The Villain's group (red/pink) clusters separately but connects to the Protagonist through direct encounters and through the Spy who bridges both worlds. The Friend group (green) orbits the Rival, connected to the hero side but with their own internal density. The Elder and Narrator drift near the hero cluster but at the periphery -- they appear less frequently.\n\nThis is what network visualization does that no other technique can: it reveals *relational structure*. Not \"how much\" or \"when\" or \"where\" -- but \"who connects to whom, and how tightly.\" The social architecture of a novel, visible in a single image. If you fed a real book through a paragraph co-occurrence counter (Project Gutenberg has plenty of free texts), the network would reveal the book's social structure in a way that reading the text cover-to-cover doesn't make explicit.\n\n## Radial layout: hierarchy as rings\n\nNot all graphs are flat peer-to-peer networks. Some have hierarchy -- a root node with children, grandchildren, great-grandchildren. Organizational charts, file system trees, taxonomy classifications. A radial tree layout places the root at the center and arranges each level of the hierarchy as a concentric ring.\n\n```javascript\nconst canvas = document.createElement('canvas');\ncanvas.width = 700;\ncanvas.height = 700;\ndocument.body.appendChild(canvas);\nconst ctx = canvas.getContext('2d');\n\n// build a tree: root has 4 children, each has 2-4 grandchildren\nconst tree = { id: 0, children: [] };\nlet nextId = 1;\n\nfor (let i = 0; i < 4; i++) {\n  const child = { id: nextId++, children: [] };\n  const numGrand = 2 + Math.floor(Math.random() * 3);\n  for (let j = 0; j < numGrand; j++) {\n    const grand = { id: nextId++, children: [] };\n    // some grandchildren have leaves\n    if (Math.random() < 0.5) {\n      const numLeaves = 1 + Math.floor(Math.random() * 3);\n      for (let k = 0; k < numLeaves; k++) {\n        grand.children.push({ id: nextId++, children: [] });\n      }\n    }\n    child.children.push(grand);\n  }\n  tree.children.push(child);\n}\n\nctx.fillStyle = '#0a0a1a';\nctx.fillRect(0, 0, 700, 700);\n\nconst cx = 350;\nconst cy = 350;\n\nfunction drawRadialTree(node, startAngle, endAngle, depth) {\n  const radius = depth * 80;\n  const angle = (startAngle + endAngle) / 2;\n\n  const x = cx + Math.cos(angle) * radius;\n  const y = cy + Math.sin(angle) * radius;\n\n  // draw edge to parent (except root)\n  if (depth > 0) {\n    const parentAngle = angle;\n    const parentR = (depth - 1) * 80;\n    const px = cx + Math.cos(parentAngle) * parentR;\n    const py = cy + Math.sin(parentAngle) * parentR;\n\n    ctx.beginPath();\n    ctx.moveTo(px, py);\n    ctx.lineTo(x, y);\n    ctx.strokeStyle = `rgba(80, 120, 180, ${0.5 - depth * 0.1})`;\n    ctx.lineWidth = 3 - depth * 0.5;\n    ctx.stroke();\n  }\n\n  // draw node\n  const r = depth === 0 ? 8 : 4;\n  const hue = 200 + depth * 40;\n  ctx.beginPath();\n  ctx.arc(x, y, r, 0, Math.PI * 2);\n  ctx.fillStyle = `hsla(${hue}, 50%, 55%, 0.7)`;\n  ctx.fill();\n\n  // recurse for children\n  if (node.children.length > 0) {\n    const sliceSize = (endAngle - startAngle) / node.children.length;\n    for (let i = 0; i < node.children.length; i++) {\n      const childStart = startAngle + i * sliceSize;\n      const childEnd = childStart + sliceSize;\n      drawRadialTree(node.children[i], childStart, childEnd, depth + 1);\n    }\n  }\n}\n\ndrawRadialTree(tree, 0, Math.PI * 2, 0);\n```\n\nThe root sits at the center. Four branches radiate outward, each claiming a quarter of the circle (roughly). Grandchildren subdivide their parent's angular range. Leaves sit at the outermost ring. The radial layout uses space efficiently -- linear tree layouts waste horizontal space because deep trees get very wide, but the radial tree wraps everything into a compact circle.\n\nThe color shifts from blue (root) to warmer tones (leaves), giving you a visual depth cue. Edge thickness decreases with depth too -- the trunk is thick, the branches thin, matching the visual metaphor of an actual tree.\n\n## What's coming\n\nWe've got geography (ep083), time (ep084), and now networks (this episode) -- three fundamentally different data structures, each with its own visual language. But most real-world data isn't neatly geographic, temporal, or relational. It's messy text -- articles, tweets, logs, books. Extracting visual patterns from text requires a different set of techniques: word frequency, sentiment analysis, linguistic structure. And text happens to be one of the richest sources of creative raw material when you know how to parse it.\n\n## 't Komt erop neer...\n\n- Networks have two elements: nodes (entities) and edges (connections). Social networks, citation graphs, hyperlinks, character co-occurrence, function call trees -- most complex systems are graphs. The visual language is node-link diagrams (circles and lines), but layout is the hard problem: where you place nodes determines whether the picture is readable or a hairball\n- Force-directed layout treats edges as springs and node pairs as repelling charges, then simulates physics until equilibrium. Connected nodes cluster together, unconnected nodes drift apart. Three forces: repulsion (all pairs, inverse square), spring attraction (edges only, Hooke's law), and centering (weak pull to canvas center). Damping drains kinetic energy so the system converges\n- Visual encoding makes nodes meaningful: size from degree (area-proportional using `Math.sqrt`), color from category or importance, edge thickness from weight. Without encoding, all nodes look the same and the graph hides its information\n- Community detection assigns colors to clusters of densely connected nodes. Label propagation is simple: each node adopts the most common label among its neighbors, iterated until convergence. Golden angle spacing (137 degrees between hues) prevents adjacent community indices from getting similar colors\n- Adjacency matrices represent edges as colored cells in a grid (row i, column j filled if there's an edge). They never have crossing edges and scale to dense graphs where node-link diagrams become hairballs. But node ordering matters critically -- random order hides the block structure, sorted order reveals it\n- Arc diagrams place nodes on a horizontal line with edges as arcs above. Arc height shows the distance between connected nodes in the linear ordering. Works beautifully for sequential data -- characters in a story, functions in source code -- where the linear order carries meaning\n- Interactive hover-and-highlight is essential for networks above ~30 nodes. Highlight one node's edges and neighbors while fading everything else. The ego-network pops out of the complexity. Same graph looks different from every node's perspective\n- Edge bundling reduces visual clutter by routing nearby edges along shared curves. Bend each edge's bezier control point toward a common attractor (graph centroid). Bundled edges at low opacity reveal major flow patterns while isolated edges fade\n- D3-force is the production standard for force layout. Import just the force module, let it compute positions, and render with canvas. Adaptive cooling converges faster than constant damping. Separation of layout engine from rendering keeps your visual style flexible\n- Radial tree layout places the root at center with each hierarchy level as a concentric ring. Children subdivide their parent's angular range. Compact and space-efficient compared to linear tree layouts. Color and line thickness shifting with depth reinforces the hierarchy visually\n\nSallukes! Thanks for reading.\n\nX\n\n@femdev\n",
      "title": "Learn Creative Coding (#85) - Network and Graph Visualization",
      "author": "femdev",
      "permlink": "learn-creative-coding-85-network-and-graph-visualization",
      "json_metadata": "{\"tags\": [\"stem\", \"stemsocial\", \"steemstem\", \"programming\", \"creativecoding\"], \"app\": \"hiveblog/0.1\", \"format\": \"markdown\", \"image\": [\"https://images.hive.blog/DQmZCVibociQsR6XsLDTrnzNBjz47h1z3ocJGFZgerPf7xM/cc-banner-red.png\"]}",
      "parent_author": "",
      "parent_permlink": "hive-196387"
    }
  ],
  "block": 107005628,
  "trx_id": "39157976d1a0075edc574ccddecafea4ea97491f",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:47:42",
  "virtual_op": false,
  "trx_in_block": 5
}
femdevclaimed reward balance: 0.508 HIVE, 0.519 HP
2026/06/05 11:28:36
accountfemdev
reward hbd0.000 HBD
reward hive0.508 HIVE
reward vests842.931561 VESTS
Transaction InfoBlock #107005247/Trx c683d8c5bbb88d5b34f0679aaeccd4f979d91f52
View Raw JSON Data
{
  "op": [
    "claim_reward_balance",
    {
      "account": "femdev",
      "reward_hbd": "0.000 HBD",
      "reward_hive": "0.508 HIVE",
      "reward_vests": "842.931561 VESTS"
    }
  ],
  "block": 107005247,
  "trx_id": "c683d8c5bbb88d5b34f0679aaeccd4f979d91f52",
  "op_in_trx": 0,
  "timestamp": "2026-06-05T11:28:36",
  "virtual_op": false,
  "trx_in_block": 10
}
2026/06/05 11:13:24
authorfemdev
permlinklearn-creative-coding-78-webxr-creative-coding-in-vrar
Transaction InfoBlock #107004943/Virtual Operation 4294967295:14
View Raw JSON Data
{
  "op": [
    "comment_payout_update",
    {
      "author": "femdev",
      "permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar"
    }
  ],
  "block": 107004943,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 14,
  "timestamp": "2026-06-05T11:13:24",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
2026/06/05 11:13:24
authorfemdev
payout0.119 HBD
permlinklearn-creative-coding-78-webxr-creative-coding-in-vrar
author rewards1016
total payout value0.059 HBD
curator payout value0.059 HBD
beneficiary payout value0.000 HBD
Transaction InfoBlock #107004943/Virtual Operation 4294967295:13
View Raw JSON Data
{
  "op": [
    "comment_reward",
    {
      "author": "femdev",
      "payout": "0.119 HBD",
      "permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar",
      "author_rewards": 1016,
      "total_payout_value": "0.059 HBD",
      "curator_payout_value": "0.059 HBD",
      "beneficiary_payout_value": "0.000 HBD"
    }
  ],
  "block": 107004943,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 13,
  "timestamp": "2026-06-05T11:13:24",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
femdevreceived 0.508 HIVE, 0.508 HP author reward for @femdev / learn-creative-coding-78-webxr-creative-coding-in-vrar
2026/06/05 11:13:24
authorfemdev
permlinklearn-creative-coding-78-webxr-creative-coding-in-vrar
hbd payout0.000 HBD
hive payout0.508 HIVE
vesting payout825.065960 VESTS
payout must be claimedtrue
curators vesting payout1643.635337 VESTS
Transaction InfoBlock #107004943/Virtual Operation 4294967295:12
View Raw JSON Data
{
  "op": [
    "author_reward",
    {
      "author": "femdev",
      "permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar",
      "hbd_payout": "0.000 HBD",
      "hive_payout": "0.508 HIVE",
      "vesting_payout": "825.065960 VESTS",
      "payout_must_be_claimed": true,
      "curators_vesting_payout": "1643.635337 VESTS"
    }
  ],
  "block": 107004943,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 12,
  "timestamp": "2026-06-05T11:13:24",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
2026/06/05 11:13:24
authorfemdev
reward17.865601 VESTS
curatorfemdev
permlinklearn-creative-coding-78-webxr-creative-coding-in-vrar
payout must be claimedtrue
Transaction InfoBlock #107004943/Virtual Operation 4294967295:6
View Raw JSON Data
{
  "op": [
    "curation_reward",
    {
      "author": "femdev",
      "reward": "17.865601 VESTS",
      "curator": "femdev",
      "permlink": "learn-creative-coding-78-webxr-creative-coding-in-vrar",
      "payout_must_be_claimed": true
    }
  ],
  "block": 107004943,
  "trx_id": "0000000000000000000000000000000000000000",
  "op_in_trx": 6,
  "timestamp": "2026-06-05T11:13:24",
  "virtual_op": true,
  "trx_in_block": 4294967295
}
femdevclaimed reward balance: 0.507 HIVE, 0.518 HP
2026/06/04 16:26:51
accountfemdev
reward hbd0.000 HBD
reward hive0.507 HIVE
reward vests841.368926 VESTS
Transaction InfoBlock #106982470/Trx f90dba46d12fca57e3694d0d41996177136b52fd
View Raw JSON Data
{
  "op": [
    "claim_reward_balance",
    {
      "account": "femdev",
      "reward_hbd": "0.000 HBD",
      "reward_hive": "0.507 HIVE",
      "reward_vests": "841.368926 VESTS"
    }
  ],
  "block": 106982470,
  "trx_id": "f90dba46d12fca57e3694d0d41996177136b52fd",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T16:26:51",
  "virtual_op": false,
  "trx_in_block": 0
}
2026/06/04 14:52:12
voterstem-shturm
authorfemdev
weight133577198
rshares133577198
permlinklearn-creative-coding-84-visualizing-time
pending payout0.123 HBD
total vote weight1575900307147
Transaction InfoBlock #106980581/Trx 1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "stem-shturm",
      "author": "femdev",
      "weight": 133577198,
      "rshares": 133577198,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.123 HBD",
      "total_vote_weight": 1575900307147
    }
  ],
  "block": 106980581,
  "trx_id": "1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T14:52:12",
  "virtual_op": true,
  "trx_in_block": 14
}
2026/06/04 14:52:12
voterstem-shturm
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106980581/Trx 1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "stem-shturm",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106980581,
  "trx_id": "1fe9f29fc5d163bb7510df6c2f64826cf92ec9f8",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T14:52:12",
  "virtual_op": false,
  "trx_in_block": 14
}
2026/06/04 12:47:39
voterhive-103505
authorfemdev
weight1803458840
rshares1803458840
permlinklearn-creative-coding-84-visualizing-time
pending payout0.125 HBD
total vote weight1575766729949
Transaction InfoBlock #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "hive-103505",
      "author": "femdev",
      "weight": 1803458840,
      "rshares": 1803458840,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.125 HBD",
      "total_vote_weight": 1575766729949
    }
  ],
  "block": 106978097,
  "trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
  "op_in_trx": 3,
  "timestamp": "2026-06-04T12:47:39",
  "virtual_op": true,
  "trx_in_block": 0
}
2026/06/04 12:47:39
voterhive-103505
authorfemdev
weight350 (3.50%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "hive-103505",
      "author": "femdev",
      "weight": 350,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106978097,
  "trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
  "op_in_trx": 2,
  "timestamp": "2026-06-04T12:47:39",
  "virtual_op": false,
  "trx_in_block": 0
}
2026/06/04 12:47:39
voterpsychophilo
authorfemdev
weight7321255
rshares7321255
permlinklearn-creative-coding-84-visualizing-time
pending payout0.124 HBD
total vote weight1573963271109
Transaction InfoBlock #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "psychophilo",
      "author": "femdev",
      "weight": 7321255,
      "rshares": 7321255,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.124 HBD",
      "total_vote_weight": 1573963271109
    }
  ],
  "block": 106978097,
  "trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:47:39",
  "virtual_op": true,
  "trx_in_block": 0
}
2026/06/04 12:47:39
voterpsychophilo
authorfemdev
weight350 (3.50%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106978097/Trx 06729e812668ffd06a68ea01e403bf9904a9338f
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "psychophilo",
      "author": "femdev",
      "weight": 350,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106978097,
  "trx_id": "06729e812668ffd06a68ea01e403bf9904a9338f",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:47:39",
  "virtual_op": false,
  "trx_in_block": 0
}
2026/06/04 12:47:30
voterwe-are-ai
authorfemdev
weight22815995045
rshares22815995045
permlinklearn-creative-coding-84-visualizing-time
pending payout0.124 HBD
total vote weight1573955949854
Transaction InfoBlock #106978094/Trx a6126fa0abf44acff58a6075b44706ed00d4db50
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "we-are-ai",
      "author": "femdev",
      "weight": 22815995045,
      "rshares": 22815995045,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.124 HBD",
      "total_vote_weight": 1573955949854
    }
  ],
  "block": 106978094,
  "trx_id": "a6126fa0abf44acff58a6075b44706ed00d4db50",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:47:30",
  "virtual_op": true,
  "trx_in_block": 3
}
2026/06/04 12:47:30
voterwe-are-ai
authorfemdev
weight350 (3.50%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106978094/Trx a6126fa0abf44acff58a6075b44706ed00d4db50
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "we-are-ai",
      "author": "femdev",
      "weight": 350,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106978094,
  "trx_id": "a6126fa0abf44acff58a6075b44706ed00d4db50",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:47:30",
  "virtual_op": false,
  "trx_in_block": 3
}
2026/06/04 12:43:57
voterjeronimorubio
authorfemdev
weight11162786581
rshares11162786581
permlinklearn-creative-coding-84-visualizing-time
pending payout0.123 HBD
total vote weight1551139954809
Transaction InfoBlock #106978023/Trx 63c540c5f86de539e69144a79698bb9c7971de30
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "jeronimorubio",
      "author": "femdev",
      "weight": 11162786581,
      "rshares": 11162786581,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.123 HBD",
      "total_vote_weight": 1551139954809
    }
  ],
  "block": 106978023,
  "trx_id": "63c540c5f86de539e69144a79698bb9c7971de30",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:43:57",
  "virtual_op": true,
  "trx_in_block": 1
}
2026/06/04 12:43:57
voterjeronimorubio
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106978023/Trx 63c540c5f86de539e69144a79698bb9c7971de30
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "jeronimorubio",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106978023,
  "trx_id": "63c540c5f86de539e69144a79698bb9c7971de30",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:43:57",
  "virtual_op": false,
  "trx_in_block": 1
}
2026/06/04 12:33:09
votercoinmarketcal
authorfemdev
weight5295191798
rshares5295191798
permlinklearn-creative-coding-84-visualizing-time
pending payout0.122 HBD
total vote weight1539977168228
Transaction InfoBlock #106977808/Trx b88ceb896133cd23062ebe807043a03c9473c32e
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "coinmarketcal",
      "author": "femdev",
      "weight": 5295191798,
      "rshares": 5295191798,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.122 HBD",
      "total_vote_weight": 1539977168228
    }
  ],
  "block": 106977808,
  "trx_id": "b88ceb896133cd23062ebe807043a03c9473c32e",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:33:09",
  "virtual_op": true,
  "trx_in_block": 1
}
2026/06/04 12:33:09
votercoinmarketcal
authorfemdev
weight2200 (22.00%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106977808/Trx b88ceb896133cd23062ebe807043a03c9473c32e
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "coinmarketcal",
      "author": "femdev",
      "weight": 2200,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106977808,
  "trx_id": "b88ceb896133cd23062ebe807043a03c9473c32e",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:33:09",
  "virtual_op": false,
  "trx_in_block": 1
}
2026/06/04 12:28:57
voternewsrx
authorfemdev
weight2060802963
rshares2060802963
permlinklearn-creative-coding-84-visualizing-time
pending payout0.121 HBD
total vote weight1534681976430
Transaction InfoBlock #106977724/Trx f349a192e0facaeedda5edf400c48c8219bbb048
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "newsrx",
      "author": "femdev",
      "weight": 2060802963,
      "rshares": 2060802963,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.121 HBD",
      "total_vote_weight": 1534681976430
    }
  ],
  "block": 106977724,
  "trx_id": "f349a192e0facaeedda5edf400c48c8219bbb048",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:28:57",
  "virtual_op": true,
  "trx_in_block": 22
}
2026/06/04 12:28:57
voternewsrx
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106977724/Trx f349a192e0facaeedda5edf400c48c8219bbb048
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "newsrx",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106977724,
  "trx_id": "f349a192e0facaeedda5edf400c48c8219bbb048",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:28:57",
  "virtual_op": false,
  "trx_in_block": 22
}
2026/06/04 12:28:42
voterblue-witness
authorfemdev
weight3621524313
rshares3621524313
permlinklearn-creative-coding-84-visualizing-time
pending payout0.121 HBD
total vote weight1532621173467
Transaction InfoBlock #106977719/Trx fed96c728e8b8a22bdf0b3f362bba84ac32fd001
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "blue-witness",
      "author": "femdev",
      "weight": 3621524313,
      "rshares": 3621524313,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.121 HBD",
      "total_vote_weight": 1532621173467
    }
  ],
  "block": 106977719,
  "trx_id": "fed96c728e8b8a22bdf0b3f362bba84ac32fd001",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:28:42",
  "virtual_op": true,
  "trx_in_block": 15
}
2026/06/04 12:28:42
voterblue-witness
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106977719/Trx fed96c728e8b8a22bdf0b3f362bba84ac32fd001
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "blue-witness",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106977719,
  "trx_id": "fed96c728e8b8a22bdf0b3f362bba84ac32fd001",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:28:42",
  "virtual_op": false,
  "trx_in_block": 15
}
2026/06/04 12:28:24
votersteem-ua
authorfemdev
weight513288209377
rshares513288209377
permlinklearn-creative-coding-84-visualizing-time
pending payout0.121 HBD
total vote weight1528999649154
Transaction InfoBlock #106977713/Trx 7e262a0debe013c427be72fff7a5529da17a61ba
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "steem-ua",
      "author": "femdev",
      "weight": 513288209377,
      "rshares": 513288209377,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.121 HBD",
      "total_vote_weight": 1528999649154
    }
  ],
  "block": 106977713,
  "trx_id": "7e262a0debe013c427be72fff7a5529da17a61ba",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:28:24",
  "virtual_op": true,
  "trx_in_block": 13
}
2026/06/04 12:28:24
votersteem-ua
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106977713/Trx 7e262a0debe013c427be72fff7a5529da17a61ba
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "steem-ua",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106977713,
  "trx_id": "7e262a0debe013c427be72fff7a5529da17a61ba",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:28:24",
  "virtual_op": false,
  "trx_in_block": 13
}
2026/06/04 12:28:18
voterscipio
authorfemdev
weight199932102555
rshares199932102555
permlinklearn-creative-coding-84-visualizing-time
pending payout0.080 HBD
total vote weight1015711439777
Transaction InfoBlock #106977711/Trx 00b64bb1c653adea87b5b6b6d5433462f3274866
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "scipio",
      "author": "femdev",
      "weight": 199932102555,
      "rshares": 199932102555,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.080 HBD",
      "total_vote_weight": 1015711439777
    }
  ],
  "block": 106977711,
  "trx_id": "00b64bb1c653adea87b5b6b6d5433462f3274866",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:28:18",
  "virtual_op": true,
  "trx_in_block": 27
}
2026/06/04 12:28:18
voterscipio
authorfemdev
weight10000 (100.00%)
permlinklearn-creative-coding-84-visualizing-time
Transaction InfoBlock #106977711/Trx 00b64bb1c653adea87b5b6b6d5433462f3274866
View Raw JSON Data
{
  "op": [
    "vote",
    {
      "voter": "scipio",
      "author": "femdev",
      "weight": 10000,
      "permlink": "learn-creative-coding-84-visualizing-time"
    }
  ],
  "block": 106977711,
  "trx_id": "00b64bb1c653adea87b5b6b6d5433462f3274866",
  "op_in_trx": 0,
  "timestamp": "2026-06-04T12:28:18",
  "virtual_op": false,
  "trx_in_block": 27
}
2026/06/04 12:28:15
voterfumegi
authorfemdev
weight3197155620
rshares3197155620
permlinklearn-creative-coding-84-visualizing-time
pending payout0.064 HBD
total vote weight815779337222
Transaction InfoBlock #106977710/Trx e85377072294bf8043619bf1e2e10067524112b9
View Raw JSON Data
{
  "op": [
    "effective_comment_vote",
    {
      "voter": "fumegi",
      "author": "femdev",
      "weight": 3197155620,
      "rshares": 3197155620,
      "permlink": "learn-creative-coding-84-visualizing-time",
      "pending_payout": "0.064 HBD",
      "total_vote_weight": 815779337222
    }
  ],
  "block": 106977710,
  "trx_id": "e85377072294bf8043619bf1e2e10067524112b9",
  "op_in_trx": 1,
  "timestamp": "2026-06-04T12:28:15",
  "virtual_op": true,
  "trx_in_block": 11
}

Account Metadata

POSTING JSON METADATA
profile{"profile_image":"https://iili.io/qJA9iv4.png","cover_image":"https://iili.io/qJA5IwB.jpg","name":"femdev","about":"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.","location":"Antwerpen, Belgium"}
JSON METADATA
profile{"profile_image":"https://iili.io/qJA9iv4.png","cover_image":"https://iili.io/qJA5IwB.jpg","name":"femdev","about":"Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.","location":"Antwerpen, Belgium"}
{
  "posting_json_metadata": {
    "profile": {
      "profile_image": "https://iili.io/qJA9iv4.png",
      "cover_image": "https://iili.io/qJA5IwB.jpg",
      "name": "femdev",
      "about": "Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.",
      "location": "Antwerpen, Belgium"
    }
  },
  "json_metadata": {
    "profile": {
      "profile_image": "https://iili.io/qJA9iv4.png",
      "cover_image": "https://iili.io/qJA5IwB.jpg",
      "name": "femdev",
      "about": "Software developer from Antwerpen. When I am not coding I am cycling along the Schelde or hunting for the best koffiekoeken in town. She/her.",
      "location": "Antwerpen, Belgium"
    }
  }
}

Auth Keys

Owner
Single Signature
Public Keys
STM7KZKwyibAFkYc2jb6gLZLQjPDyVuzfjnS5rU5hrurcK5fyu2Bj1/1
Active
Single Signature
Public Keys
STM7RmcM9PgA3GPg8dcPzL7LzoiSvZnUsFX4rDjoXfYVcXx4cHBqX1/1
Posting
Single Signature
Public Keys
STM7vHX7tx89iw5VMg3JmQwV4p2SV9npWmhPpWfGtUpEuaSy2GJjT1/1
Memo
STM8SuHVNJePs4kLBZarbJvzYsvenp8CVJ2gQEcJRhUoqbHuTDMps
{
  "owner": {
    "weight_threshold": 1,
    "account_auths": [],
    "key_auths": [
      [
        "STM7KZKwyibAFkYc2jb6gLZLQjPDyVuzfjnS5rU5hrurcK5fyu2Bj",
        1
      ]
    ]
  },
  "active": {
    "weight_threshold": 1,
    "account_auths": [],
    "key_auths": [
      [
        "STM7RmcM9PgA3GPg8dcPzL7LzoiSvZnUsFX4rDjoXfYVcXx4cHBqX",
        1
      ]
    ]
  },
  "posting": {
    "weight_threshold": 1,
    "account_auths": [],
    "key_auths": [
      [
        "STM7vHX7tx89iw5VMg3JmQwV4p2SV9npWmhPpWfGtUpEuaSy2GJjT",
        1
      ]
    ]
  },
  "memo": "STM8SuHVNJePs4kLBZarbJvzYsvenp8CVJ2gQEcJRhUoqbHuTDMps"
}

Witness Votes

0 / 30
No active witness votes.
[]