code-node-graph 0.4.0__tar.gz → 0.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (188) hide show
  1. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/CHANGELOG.md +21 -1
  2. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/PKG-INFO +2 -1
  3. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/README.md +1 -0
  4. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/__init__.py +1 -1
  5. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/graph.py +3 -1
  6. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/model.py +45 -0
  7. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/cli.py +9 -1
  8. code_node_graph-0.5.0/src/code_node/history.py +151 -0
  9. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/pages.py +6 -2
  10. code_node_graph-0.5.0/src/code_node/report/changes.py +42 -0
  11. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/graph.py +108 -5
  12. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/style.py +26 -4
  13. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/walk.py +20 -5
  14. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/tests/test_code_node.py +61 -2
  15. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/.gitignore +0 -0
  16. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/LICENSE +0 -0
  17. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/examples/books-api/README.md +0 -0
  18. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/examples/cart-service/README.md +0 -0
  19. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/examples/orders-service/README.md +0 -0
  20. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/pyproject.toml +0 -0
  21. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/__main__.py +0 -0
  22. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/__init__.py +0 -0
  23. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/aws_model.py +0 -0
  24. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/findings.py +0 -0
  25. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/mapping.py +0 -0
  26. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/reader.py +0 -0
  27. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/syntax.py +0 -0
  28. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/analysis/terms.py +0 -0
  29. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/deploys/__init__.py +0 -0
  30. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/deploys/sam.py +0 -0
  31. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/deploys/serverless.py +0 -0
  32. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/kit/__init__.py +0 -0
  33. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/kit/blocks.py +0 -0
  34. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/kit/fmt.py +0 -0
  35. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/kit/highlight.py +0 -0
  36. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/kit/render.py +0 -0
  37. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/kit/text.py +0 -0
  38. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/libraries/__init__.py +0 -0
  39. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/libraries/llm.py +0 -0
  40. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/libraries/mongo.py +0 -0
  41. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/libraries/redis.py +0 -0
  42. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/libraries/sql.py +0 -0
  43. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/platforms/__init__.py +0 -0
  44. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/platforms/aws_lambda.py +0 -0
  45. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/platforms/web.py +0 -0
  46. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/project.py +0 -0
  47. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/__init__.py +0 -0
  48. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/brands.py +0 -0
  49. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/build.py +0 -0
  50. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/editor.py +0 -0
  51. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/README.md +0 -0
  52. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/anthropic-icon.svg +0 -0
  53. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-amplify.svg +0 -0
  54. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-api-gateway.svg +0 -0
  55. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-appflow.svg +0 -0
  56. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-appsync.svg +0 -0
  57. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-athena.svg +0 -0
  58. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-backup.svg +0 -0
  59. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-batch.svg +0 -0
  60. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-certificate-manager.svg +0 -0
  61. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudformation.svg +0 -0
  62. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudfront.svg +0 -0
  63. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudtrail.svg +0 -0
  64. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cloudwatch.svg +0 -0
  65. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codebuild.svg +0 -0
  66. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codecommit.svg +0 -0
  67. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codedeploy.svg +0 -0
  68. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-codepipeline.svg +0 -0
  69. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-cognito.svg +0 -0
  70. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-config.svg +0 -0
  71. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-documentdb.svg +0 -0
  72. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-dynamodb.svg +0 -0
  73. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-ec2.svg +0 -0
  74. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-ecs.svg +0 -0
  75. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-eks.svg +0 -0
  76. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-elasticache.svg +0 -0
  77. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-elb.svg +0 -0
  78. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-eventbridge.svg +0 -0
  79. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-glacier.svg +0 -0
  80. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-glue.svg +0 -0
  81. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-iam.svg +0 -0
  82. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-keyspaces.svg +0 -0
  83. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-kinesis.svg +0 -0
  84. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-kms.svg +0 -0
  85. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-lake-formation.svg +0 -0
  86. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-lambda.svg +0 -0
  87. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-lightsail.svg +0 -0
  88. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-mq.svg +0 -0
  89. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-msk.svg +0 -0
  90. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-neptune.svg +0 -0
  91. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-open-search.svg +0 -0
  92. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-quicksight.svg +0 -0
  93. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-rds.svg +0 -0
  94. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-redshift.svg +0 -0
  95. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-route53.svg +0 -0
  96. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-s3.svg +0 -0
  97. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-secrets-manager.svg +0 -0
  98. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-ses.svg +0 -0
  99. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-shield.svg +0 -0
  100. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-sns.svg +0 -0
  101. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-sqs.svg +0 -0
  102. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-step-functions.svg +0 -0
  103. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-systems-manager.svg +0 -0
  104. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-timestream.svg +0 -0
  105. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-waf.svg +0 -0
  106. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws-xray.svg +0 -0
  107. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/aws.svg +0 -0
  108. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/azure-icon.svg +0 -0
  109. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/django-icon.svg +0 -0
  110. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/elasticsearch.svg +0 -0
  111. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/fastapi-icon.svg +0 -0
  112. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/firebase-icon.svg +0 -0
  113. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/flask.svg +0 -0
  114. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/google-cloud.svg +0 -0
  115. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/kafka-icon.svg +0 -0
  116. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/mariadb-icon.svg +0 -0
  117. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/mongodb-icon.svg +0 -0
  118. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/mysql-icon.svg +0 -0
  119. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/openai-icon.svg +0 -0
  120. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/postgresql.svg +0 -0
  121. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/python.svg +0 -0
  122. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/rabbitmq-icon.svg +0 -0
  123. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/redis.svg +0 -0
  124. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/serverless.svg +0 -0
  125. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/slack-icon.svg +0 -0
  126. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-api-gateway.svg +0 -0
  127. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-appflow.svg +0 -0
  128. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-appsync.svg +0 -0
  129. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-athena.svg +0 -0
  130. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-backup.svg +0 -0
  131. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-batch.svg +0 -0
  132. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-certificate-manager.svg +0 -0
  133. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudformation.svg +0 -0
  134. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudfront.svg +0 -0
  135. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudtrail.svg +0 -0
  136. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cloudwatch.svg +0 -0
  137. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codebuild.svg +0 -0
  138. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codecommit.svg +0 -0
  139. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codedeploy.svg +0 -0
  140. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-codepipeline.svg +0 -0
  141. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-cognito.svg +0 -0
  142. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-config.svg +0 -0
  143. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-documentdb.svg +0 -0
  144. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-dynamodb.svg +0 -0
  145. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-ec2.svg +0 -0
  146. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-ecs.svg +0 -0
  147. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-eks.svg +0 -0
  148. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-elasticache.svg +0 -0
  149. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-elb.svg +0 -0
  150. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-eventbridge.svg +0 -0
  151. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-glacier.svg +0 -0
  152. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-glue.svg +0 -0
  153. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-iam.svg +0 -0
  154. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-keyspaces.svg +0 -0
  155. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-kinesis.svg +0 -0
  156. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-kms.svg +0 -0
  157. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-lake-formation.svg +0 -0
  158. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-lambda.svg +0 -0
  159. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-mq.svg +0 -0
  160. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-msk.svg +0 -0
  161. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-neptune.svg +0 -0
  162. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-open-search.svg +0 -0
  163. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-quicksight.svg +0 -0
  164. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-rds.svg +0 -0
  165. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-redshift.svg +0 -0
  166. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-route53.svg +0 -0
  167. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-s3.svg +0 -0
  168. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-secrets-manager.svg +0 -0
  169. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-ses.svg +0 -0
  170. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-shield.svg +0 -0
  171. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-sns.svg +0 -0
  172. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-sqs.svg +0 -0
  173. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-step-functions.svg +0 -0
  174. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-systems-manager.svg +0 -0
  175. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-timestream.svg +0 -0
  176. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-waf.svg +0 -0
  177. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/small/aws-xray.svg +0 -0
  178. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/snowflake-icon.svg +0 -0
  179. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/sqlite.svg +0 -0
  180. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/stripe.svg +0 -0
  181. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/supabase-icon.svg +0 -0
  182. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/logos/twilio-icon.svg +0 -0
  183. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/outline.py +0 -0
  184. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/report/tabs.py +0 -0
  185. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/src/code_node/serve.py +0 -0
  186. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/tests/snapshots/books-api.txt +0 -0
  187. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/tests/snapshots/cart-service.txt +0 -0
  188. {code_node_graph-0.4.0 → code_node_graph-0.5.0}/tests/snapshots/orders-service.txt +0 -0
@@ -4,6 +4,25 @@ Every change a user would notice, newest first ([Keep a Changelog](https://keepa
4
4
  follow [Semantic Versioning](https://semver.org/); before 1.0, a minor version may change what a command does.
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.5.0] - 2026-10-20
8
+
9
+ ### Added
10
+
11
+ - The graph moves like a canvas: drag it to pan in any direction, hold Ctrl (⌘ on a Mac) and scroll, or pinch a
12
+ trackpad, to zoom around the pointer, and `+`, `-` and `0` when it has focus. It stays in a box as wide as the page and
13
+ about the window's height, however far it's zoomed, so the scroll bar under a tall graph is no longer the only way across;
14
+ **Fit** puts all of it in that box, and − and + buttons in its corner say how big it's drawn. This needs JavaScript;
15
+ without it the graph scrolls as before.
16
+
17
+ - Each function says what git says about it, when the folder is in git: who changed it last, how long ago and in
18
+ which commit (a link to the commit on GitHub or GitLab), how many commits and people its lines come from, and how
19
+ many of its lines changed since the last commit. It's in a box's hover card and code panel and in the step that
20
+ goes into it. It's read with `git blame`, once per file; `--no-git` leaves it out.
21
+
22
+ ### Changed
23
+
24
+ - A box's code opens in a panel over the graph, rather than pushing the graph aside on a wide screen.
25
+
7
26
  ## [0.4.0] - 2026-10-20
8
27
 
9
28
  ### Fixed
@@ -193,7 +212,8 @@ follow [Semantic Versioning](https://semver.org/); before 1.0, a minor version m
193
212
  - `--platform NAME` runs a platform's checks whatever the entry point is; `--lambda` is short for
194
213
  `--platform lambda`. `EntryPoint.is_lambda` and `PageOptions.is_lambda` are now `platform` (`"lambda"` or `""`).
195
214
 
196
- [Unreleased]: https://github.com/utkarsh5026/code-node/compare/v0.4.0...HEAD
215
+ [Unreleased]: https://github.com/utkarsh5026/code-node/compare/v0.5.0...HEAD
216
+ [0.5.0]: https://github.com/utkarsh5026/code-node/compare/v0.4.0...v0.5.0
197
217
  [0.4.0]: https://github.com/utkarsh5026/code-node/compare/v0.3.0...v0.4.0
198
218
  [0.3.0]: https://github.com/utkarsh5026/code-node/compare/v0.2.0...v0.3.0
199
219
  [0.2.0]: https://github.com/utkarsh5026/code-node/compare/v0.1.0...v0.2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: code-node-graph
3
- Version: 0.4.0
3
+ Version: 0.5.0
4
4
  Summary: See how Python code works without running it: a graph of who calls whom from an entry point, and what each path reaches.
5
5
  Project-URL: Homepage, https://github.com/utkarsh5026/code-node
6
6
  Project-URL: Documentation, https://utkarsh5026.github.io/code-node/
@@ -117,6 +117,7 @@ string is flagged.
117
117
  | `-o`, `--out`, `--no-open` | Where to save (default `~/.cache/code-node/`), and whether to open it |
118
118
  | `--platform NAME`, `--lambda` | Run a platform's checks on any entry point |
119
119
  | `--editor vscode\|cursor\|none` | Editor the line links open (default `vscode`, WSL-aware) |
120
+ | `--no-git` | Leave out what git says about each function (who changed it last, when, in which commit) |
120
121
 
121
122
  </details>
122
123
 
@@ -87,6 +87,7 @@ string is flagged.
87
87
  | `-o`, `--out`, `--no-open` | Where to save (default `~/.cache/code-node/`), and whether to open it |
88
88
  | `--platform NAME`, `--lambda` | Run a platform's checks on any entry point |
89
89
  | `--editor vscode\|cursor\|none` | Editor the line links open (default `vscode`, WSL-aware) |
90
+ | `--no-git` | Leave out what git says about each function (who changed it last, when, in which commit) |
90
91
 
91
92
  </details>
92
93
 
@@ -13,7 +13,7 @@ from .libraries import Library, register_library
13
13
  from .platforms import Platform, get_platform, register_platform
14
14
  from .project import EntryPoint, Project, entry_points, read_project
15
15
 
16
- __version__ = "0.4.0"
16
+ __version__ = "0.5.0"
17
17
 
18
18
  __all__ = [
19
19
  "CodeGraph",
@@ -15,7 +15,7 @@ from ..kit.text import _clip
15
15
 
16
16
  from .syntax import _a
17
17
  from .aws_model import _service_name
18
- from .model import AwsCall, CodeFunction, CodeMap, CodeSpot, CodeStep, LibraryCall
18
+ from .model import AwsCall, CodeFunction, CodeMap, CodeSpot, CodeStep, History, LibraryCall
19
19
  from .findings import _listed, _short_name
20
20
  from ..platforms import get_platform
21
21
 
@@ -56,6 +56,7 @@ class GraphNode:
56
56
  icon: str = "" # a service's icon, when its plugin names one: 'database', 'queue', 'spark'; or the entry
57
57
  # point's logo, when its platform names one: 'fastapi-icon'
58
58
  name: str = "" # a function's box: the function, as module.name ('orders.repo.save')
59
+ history: History | None = None # a function's box: what git says about its lines
59
60
 
60
61
 
61
62
  @dataclass
@@ -834,6 +835,7 @@ def code_graph(
834
835
  when=_when(f, f.called_from),
835
836
  icon=platform.entry_icon(cmap) if is_entry and not focus else "",
836
837
  name=f.name,
838
+ history=f.history,
837
839
  )
838
840
  )
839
841
  out_column = 1 + max([n.column for n in graph.nodes if n.kind in ("handler", "function", "start")] + [1])
@@ -173,6 +173,50 @@ class EventField:
173
173
  return _event_label(self.path)
174
174
 
175
175
 
176
+ @dataclass
177
+ class Commit:
178
+ """
179
+ A commit, as git blame tells it.
180
+ """
181
+
182
+ sha: str
183
+ author: str
184
+ time: int # when it was made, in seconds since 1970
185
+ subject: str # its message's first line
186
+ url: str = "" # its page on the repository's host, when that's GitHub or GitLab
187
+
188
+ @property
189
+ def short(self) -> str:
190
+ return self.sha[:7]
191
+
192
+
193
+ @dataclass
194
+ class History:
195
+ """
196
+ What git says about a function's lines as they are now: the commit that last changed one of them, how many commits
197
+ and people they come from, and how many have changed since the last commit. Read by history.py; None on a
198
+ function when the folder isn't in git.
199
+ """
200
+
201
+ last: Commit | None # the newest commit among its lines; None when none of them is committed yet
202
+ commits: int # the commits its lines come from
203
+ authors: list[str] # who wrote them, most lines first
204
+ first: int # when the oldest of them was made (about when the function was written), in seconds since 1970
205
+ uncommitted: int # its lines changed since the last commit
206
+ now: int # when this was read, for 'three days ago'
207
+
208
+ def ago(self, time: int) -> str:
209
+ """
210
+ 'today', '3 days ago', '2 months ago', '1 year ago'.
211
+ """
212
+ days = max(0, self.now - time) // 86_400
213
+ for size, unit in ((365, "year"), (30, "month"), (7, "week"), (1, "day")):
214
+ if days >= size:
215
+ count = days // size
216
+ return f"{count} {unit}{'' if count == 1 else 's'} ago"
217
+ return "today"
218
+
219
+
176
220
  @dataclass
177
221
  class CodeFunction:
178
222
  """
@@ -190,6 +234,7 @@ class CodeFunction:
190
234
  # nearest around the call, else the nearest `if x: return` above it ('args.serve', not every check before it)
191
235
  depth: int = 0 # calls from the entry point (the entry point is 0)
192
236
  is_async: bool = False # async def: a call that blocks in it holds up everything else on its event loop
237
+ history: History | None = None # what git says about its lines, when the folder is in git (history.py)
193
238
 
194
239
 
195
240
  @dataclass
@@ -99,6 +99,9 @@ def _parse_args(argv: list[str] | None) -> argparse.Namespace:
99
99
  default="vscode",
100
100
  help="the editor the page's line links open (default vscode; none for no links)",
101
101
  )
102
+ parser.add_argument(
103
+ "--no-git", action="store_true", help="leave out what git says about each function (who changed it, when)"
104
+ )
102
105
  return parser.parse_args(argv)
103
106
 
104
107
 
@@ -286,7 +289,12 @@ def main(argv: list[str] | None = None) -> int:
286
289
  if entry is None:
287
290
  return 1
288
291
  options = PageOptions(
289
- draw_all=args.all, functions=args.functions, depth=args.depth, platform=args.platform, editor=args.editor
292
+ draw_all=args.all,
293
+ functions=args.functions,
294
+ depth=args.depth,
295
+ platform=args.platform,
296
+ editor=args.editor,
297
+ git=not args.no_git,
290
298
  )
291
299
  if args.mermaid:
292
300
  print(make_mermaid(project.root, entry, options))
@@ -0,0 +1,151 @@
1
+ """
2
+ What git says about the functions a map shows: `git blame` on the lines of each one, once per file, for the commit
3
+ that last changed it, how many commits and people its lines come from, and the lines changed since the last commit.
4
+
5
+ git is run on the folder, never the code in it, and only to read: blame with no text conversion and no file system
6
+ monitor. A folder whose own git config names a filter command (one git would run on the files) isn't read at all.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import re
13
+ import subprocess
14
+ import time
15
+ from collections import Counter
16
+ from concurrent.futures import ThreadPoolExecutor
17
+ from pathlib import Path
18
+
19
+ from .analysis.model import CodeFunction, Commit, History
20
+
21
+ _TIMEOUT = 20 # seconds a git command may take before its file goes without history
22
+
23
+ _WORKERS = 8 # files blamed at once
24
+
25
+ _UNCOMMITTED = "0" * 40 # the commit blame gives lines changed since the last one
26
+
27
+ _HOSTS = {"github.com": "{}/commit/{}", "gitlab.com": "{}/-/commit/{}"} # a commit's page, by host
28
+
29
+ _SAFE = ["-c", "core.fsmonitor=false", "--no-pager"] # no program of the folder's config runs
30
+
31
+
32
+ def _git(root: Path, *args: str) -> str | None:
33
+ """
34
+ What a git command prints, run in root; None when git isn't there or fails (not a repository, a file it doesn't
35
+ track, a folder it doesn't trust).
36
+ """
37
+ try:
38
+ done = subprocess.run(
39
+ ["git", *_SAFE, "-C", str(root), *args],
40
+ capture_output=True,
41
+ text=True,
42
+ encoding="utf-8",
43
+ errors="replace",
44
+ timeout=_TIMEOUT,
45
+ env={**os.environ, "GIT_TERMINAL_PROMPT": "0", "GIT_OPTIONAL_LOCKS": "0"},
46
+ )
47
+ except (OSError, subprocess.SubprocessError):
48
+ return None
49
+ return done.stdout if done.returncode == 0 else None
50
+
51
+
52
+ def _runs_filters(root: Path) -> bool:
53
+ """
54
+ Whether the folder's own git config (not the user's) names a filter command, which blame would run on its files.
55
+ """
56
+ found = _git(root, "config", "--show-scope", "--get-regexp", r"^filter\..*\.(clean|process)$")
57
+ return any(line.split("\t", 1)[0] in ("local", "worktree") for line in (found or "").splitlines())
58
+
59
+
60
+ def _commit_page(remote: str) -> str:
61
+ """
62
+ The repository's web address with a {} for the sha, from its origin: 'https://github.com/o/r/commit/{}'.
63
+ """
64
+ found = re.fullmatch(r"(?:https?://(?:[^@/]+@)?|ssh://(?:[^@/]+@)?|[^@/]+@)([^/:]+)[:/](.+?)(?:\.git)?/?", remote)
65
+ if not found or found.group(1) not in _HOSTS:
66
+ return ""
67
+ return _HOSTS[found.group(1)].format(f"https://{found.group(1)}/{found.group(2)}", "{}")
68
+
69
+
70
+ def _blame(root: Path, file: str, start: int, end: int) -> dict[int, tuple[str, dict[str, str]]]:
71
+ """
72
+ Each line from start to end of the file as it is now: line -> (the commit it comes from, that commit's headers).
73
+ """
74
+ out = _git(root, "blame", "--porcelain", "--no-textconv", "-L", f"{start},{end}", "--", file)
75
+ lines: dict[int, tuple[str, dict[str, str]]] = {}
76
+ if not out:
77
+ return lines
78
+ headers: dict[str, dict[str, str]] = {}
79
+ sha = ""
80
+ for row in out.splitlines():
81
+ if row.startswith("\t"):
82
+ continue
83
+ parts = row.split(" ")
84
+ if len(parts) >= 3 and len(parts[0]) == 40 and parts[1].isdigit() and parts[2].isdigit():
85
+ sha = parts[0]
86
+ headers.setdefault(sha, {})
87
+ lines[int(parts[2])] = (sha, headers[sha])
88
+ elif sha:
89
+ key, _, value = row.partition(" ")
90
+ headers[sha].setdefault(key, value)
91
+ return lines
92
+
93
+
94
+ def _history(blamed: dict[int, tuple[str, dict[str, str]]], start: int, end: int, page: str, now: int) -> History:
95
+ """
96
+ A function's history from its lines' blame.
97
+ """
98
+ commits: dict[str, Commit] = {}
99
+ by_author: Counter[str] = Counter()
100
+ uncommitted = 0
101
+ for k in range(start, end + 1):
102
+ if k not in blamed:
103
+ continue
104
+ sha, said = blamed[k]
105
+ if sha == _UNCOMMITTED:
106
+ uncommitted += 1
107
+ continue
108
+ if sha not in commits:
109
+ commits[sha] = Commit(
110
+ sha,
111
+ said.get("author", "?"),
112
+ int(said.get("author-time", "0") or 0),
113
+ said.get("summary", ""),
114
+ page.format(sha) if page else "",
115
+ )
116
+ by_author[commits[sha].author] += 1
117
+ last = max(commits.values(), key=lambda c: c.time, default=None)
118
+ return History(
119
+ last=last,
120
+ commits=len(commits),
121
+ authors=[name for name, _ in by_author.most_common()],
122
+ first=min((c.time for c in commits.values()), default=0),
123
+ uncommitted=uncommitted,
124
+ now=now,
125
+ )
126
+
127
+
128
+ def read_history(root: Path, functions: list[CodeFunction]) -> int:
129
+ """
130
+ Fills in each function's history from git, blaming each file once over the lines its functions take up. How many
131
+ functions it found history for: 0 when the folder isn't in git, git isn't installed, or the folder's config would
132
+ have git run a program of its own.
133
+ """
134
+ spans: dict[str, tuple[int, int]] = {}
135
+ for f in functions:
136
+ if f.file and f.line and f.lines:
137
+ start, end = spans.get(f.file, (f.line, f.line + f.lines - 1))
138
+ spans[f.file] = (min(start, f.line), max(end, f.line + f.lines - 1))
139
+ if not spans or _git(root, "rev-parse", "--is-inside-work-tree") is None or _runs_filters(root):
140
+ return 0
141
+ page = _commit_page((_git(root, "remote", "get-url", "origin") or "").strip())
142
+ now = int(time.time())
143
+ with ThreadPoolExecutor(max_workers=min(_WORKERS, len(spans))) as pool:
144
+ blamed = dict(zip(spans, pool.map(lambda file: _blame(root, file, *spans[file]), spans)))
145
+ found = 0
146
+ for f in functions:
147
+ lines = blamed.get(f.file)
148
+ if lines:
149
+ f.history = _history(lines, f.line, f.line + f.lines - 1, page, now)
150
+ found += 1
151
+ return found
@@ -6,13 +6,14 @@ once, and the server makes it again on every change.
6
6
  from __future__ import annotations
7
7
 
8
8
  import os
9
- from dataclasses import dataclass
9
+ from dataclasses import dataclass, replace
10
10
  from pathlib import Path
11
11
 
12
12
  from .analysis.graph import code_graph
13
13
  from .analysis.mapping import entry_file, map_code
14
14
  from .analysis.model import CodeMap
15
15
  from .deploys import find_deployment
16
+ from .history import read_history
16
17
  from .project import EntryPoint, Project, read_project
17
18
  from .report.build import page_blocks
18
19
  from .report.editor import editor_link
@@ -38,6 +39,7 @@ class PageOptions:
38
39
  depth: int = GRAPH_DEPTH
39
40
  platform: str = "" # --platform: its checks run whatever the entry point is ('' leaves it to the entry point)
40
41
  editor: str = "vscode" # --editor: the editor its line links open ('vscode', 'cursor'; 'none' for no links)
42
+ git: bool = True # what git says about each function (--no-git leaves it out)
41
43
 
42
44
  @property
43
45
  def limits(self) -> tuple[int, int]:
@@ -63,6 +65,8 @@ def map_entry(root: Path, entry: EntryPoint, options: PageOptions) -> tuple[Code
63
65
  platform=options.platform or entry.platform,
64
66
  deployment=deployment,
65
67
  )
68
+ if options.git:
69
+ read_history(project.root, cmap.functions)
66
70
  return cmap, project
67
71
 
68
72
 
@@ -87,6 +91,6 @@ def make_mermaid(root: Path, entry: EntryPoint, options: PageOptions) -> str:
87
91
  """
88
92
  The graph as Mermaid.
89
93
  """
90
- cmap, _ = map_entry(root, entry, options)
94
+ cmap, _ = map_entry(root, entry, replace(options, git=False)) # Mermaid has nowhere to say it
91
95
  max_functions, max_depth = options.limits
92
96
  return code_graph(cmap, max_functions=max_functions, max_depth=max_depth).mermaid()
@@ -0,0 +1,42 @@
1
+ """
2
+ What git says about a function, as HTML: who changed it last and when, with a link to the commit when the repository
3
+ is on GitHub or GitLab, how many commits and people its lines come from, and its lines not committed yet. The same
4
+ in the graph's panel, its hover card and the walk's card.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from datetime import datetime, timezone
10
+
11
+ from ..kit.text import _esc
12
+
13
+ from ..analysis.model import History
14
+
15
+
16
+ def history_html(history: History | None, short: bool = False) -> str:
17
+ """
18
+ The function's history as a few lines, or '' without one. short: the last change alone, for a hover card.
19
+ """
20
+ if history is None or not (history.last or history.uncommitted):
21
+ return ""
22
+ said = []
23
+ last = history.last
24
+ if last:
25
+ day = datetime.fromtimestamp(last.time, timezone.utc).strftime("%Y-%m-%d")
26
+ sha = f"<code>{_esc(last.short)}</code>"
27
+ if last.url and not short:
28
+ sha = f'<a href="{_esc(last.url)}" target="_blank" rel="noopener" title="The commit">{sha} ↗</a>'
29
+ said.append(
30
+ f'Last changed <b title="{day}">{_esc(history.ago(last.time))}</b> by {_esc(last.author)} in {sha}'
31
+ + (f": <q>{_esc(last.subject)}</q>" if last.subject else "")
32
+ )
33
+ if history.commits > 1 and not short:
34
+ people = f"{len(history.authors)} people" if len(history.authors) > 1 else _esc(history.authors[0])
35
+ said.append(
36
+ f"Its lines come from {history.commits} commits by {people}, the oldest "
37
+ f"{_esc(history.ago(history.first))}"
38
+ )
39
+ if history.uncommitted:
40
+ changed = f"{history.uncommitted} line{'' if history.uncommitted == 1 else 's'} changed since the last commit"
41
+ said.append(f'<span class="cnhu">{changed if last else "Not committed yet"}</span>')
42
+ return '<div class="cnh">' + "".join(f"<p>{line}</p>" for line in said) + "</div>"
@@ -19,6 +19,7 @@ from ..kit.text import _esc
19
19
 
20
20
  from ..analysis.findings import _short_name
21
21
  from ..analysis.graph import CodeGraph, GraphEdge, GraphNode, GraphView
22
+ from .changes import history_html
22
23
  from .brands import AWS_LOGOS, SERVICE_LOGOS, fills, logo, small_logo
23
24
  from .editor import editor_name, open_at
24
25
  from .style import _ROOT
@@ -1195,6 +1196,7 @@ def _g_panel(n: GraphNode, lines_of: Callable[[str], list[str]], editor: str = "
1195
1196
  if n.kind in ("handler", "function", "start")
1196
1197
  else '<p class="csd csl">The lit lines are where the code uses it.</p>'
1197
1198
  )
1199
+ said += history_html(n.history)
1198
1200
  mono = " mono" if n.kind in ("handler", "function") else ""
1199
1201
  return (
1200
1202
  f'<aside class="cside" id="{_esc(panel or _g_panel_id(n))}" aria-label="{_esc("The code of " + n.label)}">'
@@ -1300,6 +1302,10 @@ def _g_tip(n: GraphNode, lines_of: Callable[[str], list[str]], opens: bool) -> t
1300
1302
  else:
1301
1303
  body.append(f'<p class="ctp">{_g_tip_text(line)}</p>')
1302
1304
  tall += 23 * math.ceil(len(line) / 42) + 10
1305
+ changed = history_html(n.history, short=True)
1306
+ if changed:
1307
+ body.append(changed)
1308
+ tall += 23 * math.ceil(len(re.sub(r"<[^>]*>", "", changed)) / 42) + 14
1303
1309
  if warnings:
1304
1310
  body.append(
1305
1311
  '<div class="ctw"><b>Warning</b><ul>'
@@ -1414,7 +1420,7 @@ _G_SCRIPT = """<script>
1414
1420
  const from = view ? document.getElementById(view)?.content : wrap.main;
1415
1421
  if (!from) return;
1416
1422
  for (const part of parts) {
1417
- wrap.querySelector(".cgb > ." + part).innerHTML = from.querySelector(".x-" + part).innerHTML;
1423
+ wrap.querySelector(".cgb ." + part).innerHTML = from.querySelector(".x-" + part).innerHTML;
1418
1424
  }
1419
1425
  wrap.dataset.view = view;
1420
1426
  wrap.querySelector(".cgb").scrollIntoView({block: "nearest"});
@@ -1424,7 +1430,7 @@ _G_SCRIPT = """<script>
1424
1430
  for (const part of parts) {
1425
1431
  const kept = document.createElement("div");
1426
1432
  kept.className = "x-" + part;
1427
- kept.innerHTML = wrap.querySelector(".cgb > ." + part).innerHTML;
1433
+ kept.innerHTML = wrap.querySelector(".cgb ." + part).innerHTML;
1428
1434
  main.content.append(kept);
1429
1435
  }
1430
1436
  wrap.main = main.content;
@@ -1440,6 +1446,97 @@ _G_SCRIPT = """<script>
1440
1446
  if (open && !open.contains(e.target) && !e.target.closest("a[href], label, input, .cgt")) close();
1441
1447
  });
1442
1448
  document.addEventListener("keydown", (e) => { if (e.key === "Escape") close(); });
1449
+ // Moving about a graph as on a canvas: drag it to pan, Ctrl or ⌘ with the wheel (or a trackpad's pinch) to zoom
1450
+ // around the pointer, and + - 0 when it has focus. A zoom of its own unchecks the bar's buttons, so the plain rule
1451
+ // (the SVG's width times --z) draws it; picking a button again takes it back. Its box keeps one size, and Fit
1452
+ // (--fit) puts all of it in that box, both ways.
1453
+ for (const wrap of document.querySelectorAll(".cgw")) {
1454
+ const box = wrap.querySelector(".cgb .cg"), radios = wrap.querySelectorAll(":scope > .cgs[type=radio]");
1455
+ const said = wrap.querySelector(".cgtp"), svg = () => box.querySelector("svg");
1456
+ const scale = () => {
1457
+ const s = svg();
1458
+ return s && s.viewBox.baseVal.width ? s.getBoundingClientRect().width / s.viewBox.baseVal.width : 1;
1459
+ };
1460
+ const fit = () => {
1461
+ const s = svg(), w = s && s.viewBox.baseVal.width, h = s && s.viewBox.baseVal.height;
1462
+ if (w && h && box.clientWidth) {
1463
+ box.style.setProperty("--fit", Math.min((box.clientWidth - 24) / w, (box.clientHeight - 24) / h, 1.5));
1464
+ }
1465
+ };
1466
+ const tell = () => { fit(); if (said) said.textContent = Math.round(scale() * 100) + "%"; };
1467
+ const pan = (dx, dy) => { // the graph's box first, then the page for what it can't scroll
1468
+ const top = box.scrollTop;
1469
+ box.scrollLeft += dx;
1470
+ box.scrollTop += dy;
1471
+ const left = dy - (box.scrollTop - top);
1472
+ if (Math.abs(left) > 0.5) scrollBy(0, left);
1473
+ };
1474
+ const zoom = (by, x, y) => { // keeps the point at (x, y) where it is
1475
+ const s = svg();
1476
+ if (!s) return;
1477
+ const from = scale(), to = Math.min(4, Math.max(0.1, from * by)), was = s.getBoundingClientRect();
1478
+ const px = (x - was.left) / from, py = (y - was.top) / from;
1479
+ radios.forEach((r) => { r.checked = false; });
1480
+ box.style.setProperty("--z", to);
1481
+ const now = s.getBoundingClientRect();
1482
+ pan(now.left + px * to - x, now.top + py * to - y);
1483
+ tell();
1484
+ };
1485
+ const auto = () => { // back to the Auto button
1486
+ const radio = wrap.querySelector(":scope > .cgz-auto");
1487
+ radio.checked = true;
1488
+ radio.dispatchEvent(new Event("change", {bubbles: true}));
1489
+ };
1490
+ const step = (by) => { // around the middle of what's in sight
1491
+ const r = box.getBoundingClientRect();
1492
+ zoom(by, r.left + r.width / 2, (Math.max(r.top, 0) + Math.min(r.bottom, innerHeight)) / 2);
1493
+ };
1494
+ wrap.querySelector(".cgb .zi")?.addEventListener("click", () => step(1.25));
1495
+ wrap.querySelector(".cgb .zo")?.addEventListener("click", () => step(0.8));
1496
+ let drag = null, moved = false;
1497
+ box.addEventListener("pointerdown", (e) => {
1498
+ moved = false;
1499
+ if (e.button === 0 && e.pointerType !== "touch") drag = {x: e.clientX, y: e.clientY, id: e.pointerId};
1500
+ });
1501
+ box.addEventListener("pointermove", (e) => {
1502
+ if (!drag || e.pointerId !== drag.id) return;
1503
+ const dx = e.clientX - drag.x, dy = e.clientY - drag.y;
1504
+ if (!moved && Math.hypot(dx, dy) < 4) return; // a click on a box stays a click
1505
+ if (!moved) { moved = true; box.setPointerCapture(e.pointerId); box.classList.add("pan"); }
1506
+ pan(-dx, -dy);
1507
+ drag.x = e.clientX;
1508
+ drag.y = e.clientY;
1509
+ });
1510
+ const end = () => { drag = null; box.classList.remove("pan"); };
1511
+ box.addEventListener("pointerup", end);
1512
+ box.addEventListener("pointercancel", end);
1513
+ box.addEventListener("click", (e) => { if (moved) { e.preventDefault(); e.stopPropagation(); } }, true);
1514
+ box.addEventListener("dragstart", (e) => e.preventDefault());
1515
+ box.addEventListener("wheel", (e) => {
1516
+ if (!e.ctrlKey && !e.metaKey) return;
1517
+ e.preventDefault();
1518
+ const d = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1);
1519
+ zoom(Math.exp(-Math.max(-100, Math.min(100, d)) * 0.0015), e.clientX, e.clientY);
1520
+ }, {passive: false});
1521
+ box.tabIndex = 0;
1522
+ box.addEventListener("keydown", (e) => {
1523
+ if (e.ctrlKey || e.metaKey || e.altKey) return;
1524
+ if (e.key === "+" || e.key === "=") step(1.25);
1525
+ else if (e.key === "-" || e.key === "_") step(0.8);
1526
+ else if (e.key === "0") auto();
1527
+ else return;
1528
+ e.preventDefault();
1529
+ });
1530
+ wrap.addEventListener("change", (e) => {
1531
+ if (!e.target.matches(".cgs")) return; // a zoom button, or full screen: its box's size changes
1532
+ if (e.target.type === "radio") box.style.removeProperty("--z");
1533
+ requestAnimationFrame(tell);
1534
+ });
1535
+ addEventListener("hashchange", () => requestAnimationFrame(tell));
1536
+ addEventListener("resize", tell);
1537
+ wrap.classList.add("pz");
1538
+ tell();
1539
+ }
1443
1540
  })();
1444
1541
  </script>"""
1445
1542
 
@@ -1604,7 +1701,7 @@ def _g_canvas(
1604
1701
  hint = (
1605
1702
  '<span class="kt">Point at a box to see only what it\'s joined to'
1606
1703
  + ("; click it to read its code" if panel_of else "")
1607
- + "</span>"
1704
+ + '</span><span class="kt pzo">Drag to move about; Ctrl or ⌘ and scroll, or pinch, to zoom</span>'
1608
1705
  )
1609
1706
  svg = (
1610
1707
  f'<svg viewBox="0 0 {width:.0f} {tall:.0f}" width="{width:.0f}" height="{tall:.0f}" '
@@ -1681,8 +1778,14 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1681
1778
  + f'</span><label class="cgtf" for="{group}-full"><span class="on">Full screen</span>'
1682
1779
  '<span class="off">Close full screen</span></label></div>'
1683
1780
  )
1781
+ zoom = ( # over the graph's corner, as a canvas has it: with JavaScript only
1782
+ '<span class="cgtm pzo" role="group" aria-label="Zoom out or in">'
1783
+ '<button type="button" class="zo" title="Zoom out (-, or Ctrl and scroll)">−</button>'
1784
+ '<span class="cgtp" title="How big the graph is drawn"></span>'
1785
+ '<button type="button" class="zi" title="Zoom in (+, or Ctrl and scroll)">+</button></span>'
1786
+ )
1684
1787
  script = ""
1685
- if (views or panels) and not report.shared("graph-script", lambda: [False])[0]:
1788
+ if not report.shared("graph-script", lambda: [False])[0]: # once, for every graph: moving about and zooming
1686
1789
  report.shared("graph-script", lambda: [False])[0] = True
1687
1790
  script = _G_SCRIPT
1688
1791
  # in a box of its own with its panels: the :has() rules that outline a box whose code is open look only in here,
@@ -1690,7 +1793,7 @@ def _graph_html(block: _Graph, report: _Report) -> str:
1690
1793
  data = f' data-group="{group}"' if views else ""
1691
1794
  return (
1692
1795
  f'{title}<div class="cgw"{data}>{switches}<div class="cgb">{bar}'
1693
- f'<nav class="cgc" aria-label="Where this graph starts"></nav><div class="cg">{svg}</div>'
1796
+ f'<nav class="cgc" aria-label="Where this graph starts"></nav><div class="cgv"><div class="cg">{svg}</div>{zoom}</div>'
1694
1797
  f'<div class="cgk">{legend}</div></div>{"".join(html for _, html in panels.values())}{"".join(views)}</div>'
1695
1798
  f"{script}"
1696
1799
  )
@@ -102,7 +102,8 @@ _EXTRA_CSS = """\
102
102
  .cnd :is(.flow,.cside,.walk,.ctip) .pa{font-style:italic}
103
103
  .cnd :is(.flow,.cside,.walk,.ctip) .pc{opacity:.6;font-style:italic}
104
104
  .cnd .cgb{width:fit-content;max-width:min(100%,1180px)}
105
- .cnd .cg{border:1px solid rgba(127,127,127,.2);border-radius:14px;margin:6px 0 8px;overflow:auto;background-color:rgba(127,127,127,.035);background-attachment:local;background-size:18px 18px;
105
+ .cnd .cgv{position:relative;margin:6px 0 8px}
106
+ .cnd .cg{border:1px solid rgba(127,127,127,.2);border-radius:14px;overflow:auto;background-color:rgba(127,127,127,.035);background-attachment:local;background-size:18px 18px;
106
107
  background-image:radial-gradient(circle at 1px 1px,rgba(127,127,127,.3) 1px,transparent 0);background-position:9px 9px}
107
108
  .cnd .cg svg{display:block;width:calc(var(--w)*var(--z,1));max-width:none;height:auto;overflow:visible;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif}
108
109
  .cnd .cgw{position:relative}
@@ -121,11 +122,25 @@ _EXTRA_CSS = """\
121
122
  .cnd .cgz-auto:checked~.cgb .cg svg{width:var(--w);max-width:100%;min-width:calc(var(--w)*.8)}
122
123
  .cnd .cgz-fit:checked~.cgb .cg svg{width:var(--w);max-width:100%}
123
124
  .cnd .cgfs:checked~.cgb{position:fixed;inset:0;z-index:25;width:auto;max-width:none;display:flex;flex-direction:column;box-sizing:border-box;padding:12px clamp(12px,2vw,28px) 4px;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas))}
124
- .cnd .cgfs:checked~.cgb .cg{flex:1;min-height:0;margin:0 0 8px}
125
+ .cnd .cgfs:checked~.cgb .cgv{flex:1;min-height:0;display:flex;flex-direction:column;margin:0 0 8px}
126
+ .cnd .cgfs:checked~.cgb .cg{flex:1;min-height:0}
125
127
  .cnd .cgfs:checked~.cgz-fit:checked~.cgb .cg svg{width:100%;height:100%;max-width:none}
126
128
  .cnd .cgfs:checked~.cgz-auto:checked~.cgb .cg svg{min-width:0}
127
- @media (min-width:1100px){.cnd .cgw:has(.cside:target) .cgfs:checked~.cgb{right:min(660px,94vw)}}
128
129
  .cnd .cgw:not(.js) :is(.oj,.dr),.cnd .cgw.js .nj{display:none}
130
+ .cnd .cgw:not(.pz) .pzo{display:none}
131
+ .cnd .cgw.pz .cg{cursor:grab;user-select:none;-webkit-user-select:none}
132
+ .cnd .cgw.pz .cg.pan,.cnd .cgw.pz .cg.pan *{cursor:grabbing}
133
+ .cnd .cgw.pz .cg:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa);outline-offset:-2px}
134
+ .cnd .cgw.pz>.cgfs:not(:checked)~.cgb{width:100%;max-width:none}
135
+ .cnd .cgw.pz>.cgfs:not(:checked)~.cgb .cg{height:max(360px,72vh)}
136
+ .cnd .cgw.pz .cg{display:flex}
137
+ .cnd .cgw.pz .cg svg{flex:none;margin:auto}
138
+ .cnd .cgw.pz>.cgz-fit:checked~.cgb .cg svg{width:calc(var(--w)*var(--fit,1));height:auto;max-width:none}
139
+ .cnd .cgtm{position:absolute;right:14px;bottom:14px;z-index:2;display:inline-flex;align-items:center;font-size:12px;user-select:none;-webkit-user-select:none;border:1px solid rgba(127,127,127,.3);border-radius:8px;overflow:hidden;background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas));box-shadow:0 1px 2px rgba(0,0,0,.06),0 4px 12px rgba(15,23,42,.08)}
140
+ .cnd .cgtm button{font:inherit;font-size:16px;line-height:1;color:inherit;background:none;border:0;padding:6px 11px;cursor:pointer;opacity:.75}
141
+ .cnd .cgtm button:hover{opacity:1;background:rgba(127,127,127,.08)}
142
+ .cnd .cgtm button:focus-visible{outline:2px solid light-dark(#2563eb,#60a5fa);outline-offset:-2px}
143
+ .cnd .cgtp{min-width:3.4em;padding:3px 2px;text-align:center;opacity:.7;font-variant-numeric:tabular-nums;border-inline:1px solid rgba(127,127,127,.2)}
129
144
  .cnd .cgta{padding:3px 10px;border:1px solid rgba(127,127,127,.28);border-radius:8px;color:inherit;text-decoration:none;opacity:.7}
130
145
  .cnd .cgta:hover{opacity:.95;background:rgba(127,127,127,.08)}
131
146
  .cnd .cgw[data-view$="-all"] .cgta{opacity:1;font-weight:650;background:rgba(127,127,127,.14)}
@@ -284,7 +299,6 @@ _EXTRA_CSS = """\
284
299
  .cnd .cside .sl.lit{background:rgba(245,158,11,.16);box-shadow:inset 3px 0 #f59e0b}
285
300
  .cnd .cside .sn{display:inline-block;width:3.2em;padding-right:14px;text-align:right;opacity:.42;user-select:none;-webkit-user-select:none}
286
301
  .cnd .cside .csm{font-size:12px;font-style:italic;opacity:.6;margin:6px 2px 0}
287
- @media (min-width:1100px){.cnd .cgw:has(.cside:target){margin-right:min(660px,94vw)}}
288
302
  @media (prefers-reduced-motion:reduce){.cnd .cside,.cnd .cside:target{transition:none}}
289
303
  .cnd .tabs{position:relative;margin:0 0 8px}
290
304
  .cnd .tbar{position:sticky;top:0;z-index:20;display:flex;flex-wrap:wrap;align-items:flex-end;gap:2px;padding-top:6px;margin:0 0 18px;border-bottom:1px solid rgba(127,127,127,.22);background:var(--jp-layout-color0,var(--vscode-editor-background,Canvas))}
@@ -372,6 +386,14 @@ _EXTRA_CSS = """\
372
386
  .cnd .walk .wsay p{margin:4px 0 0;opacity:.85}
373
387
  .cnd .walk .wsay code{font-size:12.5px}
374
388
  .cnd .walk .wdoc{font-style:italic;opacity:.7}
389
+ .cnd .cnh{margin:10px 0 0;padding:8px 0 0 10px;border-left:2px solid rgba(127,127,127,.3);font-size:12.5px;line-height:1.5}
390
+ .cnd .cnh p{margin:0 0 3px;opacity:.75;overflow-wrap:anywhere}
391
+ .cnd .cnh p:last-child{margin-bottom:0}
392
+ .cnd .cnh b{font-weight:600}
393
+ .cnd .cnh code{font:12px var(--mono)}
394
+ .cnd .cnh a{color:light-dark(#2563eb,#60a5fa);text-decoration:none}
395
+ .cnd .cnh a:hover{text-decoration:underline}
396
+ .cnd .cnh .cnhu{color:light-dark(#b45309,#fbbf24)}
375
397
  .cnd .walk .wjump{user-select:none;-webkit-user-select:none;color:var(--blue);cursor:pointer;text-decoration:underline;text-underline-offset:2px}
376
398
  .cnd .walk .wfacts{list-style:none;margin:10px 0 0;padding:10px 0 0;border-top:1px solid rgba(127,127,127,.14);display:flex;flex-direction:column;gap:5px}
377
399
  .cnd .walk .wfacts li{display:flex;align-items:baseline;flex-wrap:wrap;gap:4px 8px;font-size:12.5px;overflow-wrap:anywhere}