From 4541517c18f7db920953f74798a14352f422da88 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Tue, 10 Mar 2026 19:29:15 +0100 Subject: [PATCH 1/5] dash app structure with graph example --- eu_fact_force/exploration/cytoscape/README.md | 1 + eu_fact_force/exploration/cytoscape/app.py | 91 +++++++++++ .../exploration/cytoscape/assets/custom.css | 28 ++++ .../exploration/cytoscape/assets/icon.png | Bin 0 -> 26569 bytes .../cytoscape/assets/template.json | 46 ++++++ .../exploration/cytoscape/utils/colors.py | 4 + .../exploration/cytoscape/utils/graph.py | 44 +++++ pyproject.toml | 5 + uv.lock | 150 ++++++++++++++++++ 9 files changed, 369 insertions(+) create mode 100644 eu_fact_force/exploration/cytoscape/README.md create mode 100644 eu_fact_force/exploration/cytoscape/app.py create mode 100644 eu_fact_force/exploration/cytoscape/assets/custom.css create mode 100644 eu_fact_force/exploration/cytoscape/assets/icon.png create mode 100644 eu_fact_force/exploration/cytoscape/assets/template.json create mode 100644 eu_fact_force/exploration/cytoscape/utils/colors.py create mode 100644 eu_fact_force/exploration/cytoscape/utils/graph.py diff --git a/eu_fact_force/exploration/cytoscape/README.md b/eu_fact_force/exploration/cytoscape/README.md new file mode 100644 index 0000000..68e1a55 --- /dev/null +++ b/eu_fact_force/exploration/cytoscape/README.md @@ -0,0 +1 @@ +### Exploration - Dash app for testing Cytoscape \ No newline at end of file diff --git a/eu_fact_force/exploration/cytoscape/app.py b/eu_fact_force/exploration/cytoscape/app.py new file mode 100644 index 0000000..4632e26 --- /dev/null +++ b/eu_fact_force/exploration/cytoscape/app.py @@ -0,0 +1,91 @@ +from dash import Dash, dcc, html +import dash_bootstrap_components as dbc +import plotly.io as pio +import plotly.graph_objects as go +import dash_cytoscape as cyto + +import json + +from utils.colors import AppColors +from utils.graph import elements, stylesheet + +# Plotly template +with open("assets/template.json", "r") as f: + debate_template = json.load(f) +pio.templates["app_template"] = go.layout.Template(debate_template) +pio.templates.default = "app_template" + +# Dash app +app = Dash( + __name__, + suppress_callback_exceptions=True, + external_stylesheets=["custom.css", dbc.themes.BOOTSTRAP], +) + +# Dash params +DASHBOARD_NAME = "EU Fact Force" + +# Custom dash app tab and logo +app.title = DASHBOARD_NAME +app._favicon = "icon.png" + +# Header +header = html.Div( + dbc.Row( + dbc.Col( + html.Div( + [ + html.Img(src="assets/icon.png", alt="image", height=50), + html.H1( + DASHBOARD_NAME, + style={ + "color": AppColors.blue, + "margin": "0", + "padding": "0", + }, + ), + ], + style={ + "display": "flex", + "alignItems": "center", + "gap": "0px", + }, + ), + width=12, + ), + className="g-0", + ), + style={ + "padding": "1rem", + "background-color": AppColors.green, + "position": "fixed", + "width": "100%", + "zIndex": 1000, + }, +) +# Content +graph_example = cyto.Cytoscape( + id="graph", + elements=elements, + stylesheet=stylesheet, + layout={"name": "cose"}, + style={"width": "100%", "height": "400px"}, +) + + +content = html.Div( + children=graph_example, + id="page-content", + style={ + "margin-left": "1rem", + "margin-right": "1rem", + "padding": "1rem", + "padding-top": "120px", + }, +) + +# Layout +app.layout = html.Div([dcc.Location(id="url", refresh=False), header, content]) + +if __name__ == "__main__": + app.run(debug=True) diff --git a/eu_fact_force/exploration/cytoscape/assets/custom.css b/eu_fact_force/exploration/cytoscape/assets/custom.css new file mode 100644 index 0000000..08aae40 --- /dev/null +++ b/eu_fact_force/exploration/cytoscape/assets/custom.css @@ -0,0 +1,28 @@ +/* Colors */ +:root { + /* Principal Colors */ + --color-0: #CBDF40; + --color-1: #36C3D7; + --color-2: #F5A414; +} + + +/* Body and text style */ +body { + font-family: "Helvetica", sans-serif; + background-color: white; + font-size: 14px; +} + +h1, +h2, +h3, +h4, +h5, +h6 { + color: black; +} + +hr { + color: black; +} \ No newline at end of file diff --git a/eu_fact_force/exploration/cytoscape/assets/icon.png b/eu_fact_force/exploration/cytoscape/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..771afe49d5a699391c9736efc1bcdeddf23914bd GIT binary patch literal 26569 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 eGs@`;dcIP|#dJE&>!lM&NJ=0.15.0", "seaborn>=0.13.2", ] +graph = [ + "dash>=4.0.0", + "dash-bootstrap-components>=2.0.4", + "dash-cytoscape>=1.0.2", +] parsing = [ "docling>=2.73.1", "docling-hierarchical-pdf>=0.1.3", diff --git a/uv.lock b/uv.lock index ee70200..3e83b5d 100644 --- a/uv.lock +++ b/uv.lock @@ -32,6 +32,11 @@ dev = [ { name = "ruff" }, { name = "seaborn" }, ] +graph = [ + { name = "dash" }, + { name = "dash-bootstrap-components" }, + { name = "dash-cytoscape" }, +] parsing = [ { name = "docling" }, { name = "docling-hierarchical-pdf" }, @@ -65,6 +70,11 @@ dev = [ { name = "ruff", specifier = ">=0.15.0" }, { name = "seaborn", specifier = ">=0.13.2" }, ] +graph = [ + { name = "dash", specifier = ">=4.0.0" }, + { name = "dash-bootstrap-components", specifier = ">=2.0.4" }, + { name = "dash-cytoscape", specifier = ">=1.0.2" }, +] parsing = [ { name = "docling", specifier = ">=2.73.1" }, { name = "docling-hierarchical-pdf", specifier = ">=0.1.3" }, @@ -403,6 +413,15 @@ css = [ { name = "tinycss2" }, ] +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460, upload-time = "2024-11-08T17:25:47.436Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" }, +] + [[package]] name = "boto3" version = "1.42.59" @@ -703,6 +722,47 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "dash" +version = "4.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "flask" }, + { name = "importlib-metadata" }, + { name = "nest-asyncio" }, + { name = "plotly" }, + { name = "requests" }, + { name = "retrying" }, + { name = "setuptools" }, + { name = "typing-extensions" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/20/dd/3aed9bfd81dfd8f44b3a5db0583080ac9470d5e92ee134982bd5c69e286e/dash-4.0.0.tar.gz", hash = "sha256:c5f2bca497af288f552aea3ae208f6a0cca472559003dac84ac21187a1c3a142", size = 6943263, upload-time = "2026-02-03T19:42:27.92Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/8c/dd63d210b28a7589f4bc1e84880525368147425c717d12834ab562f52d14/dash-4.0.0-py3-none-any.whl", hash = "sha256:e36b4b4eae9e1fa4136bf4f1450ed14ef76063bc5da0b10f8ab07bd57a7cb1ab", size = 7247521, upload-time = "2026-02-03T19:42:25.01Z" }, +] + +[[package]] +name = "dash-bootstrap-components" +version = "2.0.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/cc/d4/5b7da808ff5acb3a6ca702f504d8ef05bc7d4c475b18dadefd783b1120c3/dash_bootstrap_components-2.0.4.tar.gz", hash = "sha256:c3206c0923774bbc6a6ddaa7822b8d9aa5326b0d3c1e7cd795cc975025fe2484", size = 115599, upload-time = "2025-08-20T19:42:09.449Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d6/38/1efeec8b4d741c09ccd169baf8a00c07a0176b58e418d4cd0c30dffedd22/dash_bootstrap_components-2.0.4-py3-none-any.whl", hash = "sha256:767cf0084586c1b2b614ccf50f79fe4525fdbbf8e3a161ed60016e584a14f5d1", size = 204044, upload-time = "2025-08-20T19:42:07.928Z" }, +] + +[[package]] +name = "dash-cytoscape" +version = "1.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dash" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ea/b7/0d511af853024241dc3192bea77e4753ea606187bd2dd777a8209a5b01bb/dash_cytoscape-1.0.2.tar.gz", hash = "sha256:a61019d2184d63a2b3b5c06d056d3b867a04223a674cc3c7cf900a561a9a59aa", size = 3992593, upload-time = "2024-07-15T11:39:06.185Z" } + [[package]] name = "dataclasses-json" version = "0.6.7" @@ -1033,6 +1093,23 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" }, ] +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004, upload-time = "2026-02-19T05:00:57.678Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424, upload-time = "2026-02-19T05:00:56.027Z" }, +] + [[package]] name = "fonttools" version = "4.61.1" @@ -1374,6 +1451,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0e/61/66938bbb5fc52dbdf84594873d5b51fb1f7c7794e9c0f5bd885f30bc507b/idna-3.11-py3-none-any.whl", hash = "sha256:771a87f49d9defaf64091e6e6fe9c18d4833f140bd19464795bc32d966ca37ea", size = 71008, upload-time = "2025-10-12T14:55:18.883Z" }, ] +[[package]] +name = "importlib-metadata" +version = "8.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "zipp" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f3/49/3b30cad09e7771a4982d9975a8cbf64f00d4a1ececb53297f1d9a7be1b10/importlib_metadata-8.7.1.tar.gz", hash = "sha256:49fef1ae6440c182052f407c8d34a68f72efc36db9ca90dc0113398f2fdde8bb", size = 57107, upload-time = "2025-12-21T10:00:19.278Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fa/5e/f8e9a1d23b9c20a551a8a02ea3637b4642e22c2626e3a13a9a29cdea99eb/importlib_metadata-8.7.1-py3-none-any.whl", hash = "sha256:5a1f80bf1daa489495071efbb095d75a634cf28a8bc299581244063b53176151", size = 27865, upload-time = "2025-12-21T10:00:18.329Z" }, +] + [[package]] name = "inflection" version = "0.5.1" @@ -1477,6 +1566,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/55/e5326141505c5d5e34c5e0935d2908a74e4561eca44108fbfb9c13d2911a/isoduration-20.11.0-py3-none-any.whl", hash = "sha256:b2904c2a4228c3d44f409c8ae8e2370eb21a26f7ac2ec5446df141dde3452042", size = 11321, upload-time = "2020-11-01T10:59:58.02Z" }, ] +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410, upload-time = "2024-04-16T21:28:15.614Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234, upload-time = "2024-04-16T21:28:14.499Z" }, +] + [[package]] name = "jedi" version = "0.19.2" @@ -2423,6 +2521,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" }, ] +[[package]] +name = "narwhals" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/b4/02a8add181b8d2cd5da3b667cd102ae536e8c9572ab1a130816d70a89edb/narwhals-2.18.0.tar.gz", hash = "sha256:1de5cee338bc17c338c6278df2c38c0dd4290499fcf70d75e0a51d5f22a6e960", size = 620222, upload-time = "2026-03-10T15:51:27.14Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/75/0b4a10da17a44cf13567d08a9c7632a285297e46253263f1ae119129d10a/narwhals-2.18.0-py3-none-any.whl", hash = "sha256:68378155ee706ac9c5b25868ef62ecddd62947b6df7801a0a156bc0a615d2d0d", size = 444865, upload-time = "2026-03-10T15:51:24.085Z" }, +] + [[package]] name = "nbclient" version = "0.10.4" @@ -2976,6 +3083,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/48/31/05e764397056194206169869b50cf2fee4dbbbc71b344705b9c0d878d4d8/platformdirs-4.9.2-py3-none-any.whl", hash = "sha256:9170634f126f8efdae22fb58ae8a0eaa86f38365bc57897a6c4f781d1f5875bd", size = 21168, upload-time = "2026-02-16T03:56:08.891Z" }, ] +[[package]] +name = "plotly" +version = "6.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "narwhals" }, + { name = "packaging" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/24/fb/41efe84970cfddefd4ccf025e2cbfafe780004555f583e93dba3dac2cdef/plotly-6.6.0.tar.gz", hash = "sha256:b897f15f3b02028d69f755f236be890ba950d0a42d7dfc619b44e2d8cea8748c", size = 7027956, upload-time = "2026-03-02T21:10:25.321Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/52/d2/c6e44dba74f17c6216ce1b56044a9b93a929f1c2d5bdaff892512b260f5e/plotly-6.6.0-py3-none-any.whl", hash = "sha256:8d6daf0f87412e0c0bfe72e809d615217ab57cc715899a1e5145135a7800d1d0", size = 9910315, upload-time = "2026-03-02T21:10:18.131Z" }, +] + [[package]] name = "pluggy" version = "1.6.0" @@ -3906,6 +4026,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/1e/db/4254e3eabe8020b458f1a747140d32277ec7a271daf1d235b70dc0b4e6e3/requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6", size = 64738, upload-time = "2025-08-18T20:46:00.542Z" }, ] +[[package]] +name = "retrying" +version = "1.4.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c8/5a/b17e1e257d3e6f2e7758930e1256832c9ddd576f8631781e6a072914befa/retrying-1.4.2.tar.gz", hash = "sha256:d102e75d53d8d30b88562d45361d6c6c934da06fab31bd81c0420acb97a8ba39", size = 11411, upload-time = "2025-08-03T03:35:25.189Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/f3/6cd296376653270ac1b423bb30bd70942d9916b6978c6f40472d6ac038e7/retrying-1.4.2-py3-none-any.whl", hash = "sha256:bbc004aeb542a74f3569aeddf42a2516efefcdaff90df0eb38fbfbf19f179f59", size = 10859, upload-time = "2025-08-03T03:35:23.829Z" }, +] + [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -4961,6 +5090,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/34/db/b10e48aa8fff7407e67470363eac595018441cf32d5e1001567a7aeba5d2/websocket_client-1.9.0-py3-none-any.whl", hash = "sha256:af248a825037ef591efbf6ed20cc5faa03d3b47b9e5a2230a529eeee1c1fc3ef", size = 82616, upload-time = "2025-10-07T21:16:34.951Z" }, ] +[[package]] +name = "werkzeug" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/f1/ee81806690a87dab5f5653c1f146c92bc066d7f4cebc603ef88eb9e13957/werkzeug-3.1.6.tar.gz", hash = "sha256:210c6bede5a420a913956b4791a7f4d6843a43b6fcee4dfa08a65e93007d0d25", size = 864736, upload-time = "2026-02-19T15:17:18.884Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4d/ec/d58832f89ede95652fd01f4f24236af7d32b70cab2196dfcc2d2fd13c5c2/werkzeug-3.1.6-py3-none-any.whl", hash = "sha256:7ddf3357bb9564e407607f988f683d72038551200c704012bb9a4c523d42f131", size = 225166, upload-time = "2026-02-19T15:17:17.475Z" }, +] + [[package]] name = "widgetsnbextension" version = "4.0.15" @@ -5136,3 +5277,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/47/3fa2286c3cb162c71cdb34c4224d5745a1ceceb391b2bd9b19b668a8d724/yarl-1.23.0-cp314-cp314t-win_arm64.whl", hash = "sha256:44bb7bef4ea409384e3f8bc36c063d77ea1b8d4a5b2706956c0d6695f07dcc25", size = 86041, upload-time = "2026-03-01T22:07:49.026Z" }, { url = "https://files.pythonhosted.org/packages/69/68/c8739671f5699c7dc470580a4f821ef37c32c4cb0b047ce223a7f115757f/yarl-1.23.0-py3-none-any.whl", hash = "sha256:a2df6afe50dea8ae15fa34c9f824a3ee958d785fd5d089063d960bae1daa0a3f", size = 48288, upload-time = "2026-03-01T22:07:51.388Z" }, ] + +[[package]] +name = "zipp" +version = "3.23.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/02/0f2892c661036d50ede074e376733dca2ae7c6eb617489437771209d4180/zipp-3.23.0.tar.gz", hash = "sha256:a07157588a12518c9d4034df3fbbee09c814741a33ff63c05fa29d26a2404166", size = 25547, upload-time = "2025-06-08T17:06:39.4Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2e/54/647ade08bf0db230bfea292f893923872fd20be6ac6f53b2b936ba839d75/zipp-3.23.0-py3-none-any.whl", hash = "sha256:071652d6115ed432f5ce1d34c336c0adfd6a884660d1e9712a256d3d3bd4b14e", size = 10276, upload-time = "2025-06-08T17:06:38.034Z" }, +] From 2c7fad7e5e30787e5ce78d7643c53f82830a8034 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Wed, 11 Mar 2026 11:59:43 +0100 Subject: [PATCH 2/5] design improvements --- eu_fact_force/exploration/cytoscape/app.py | 20 +++++++++++++------ .../exploration/cytoscape/assets/custom.css | 3 ++- .../exploration/cytoscape/utils/colors.py | 2 ++ 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/eu_fact_force/exploration/cytoscape/app.py b/eu_fact_force/exploration/cytoscape/app.py index 4632e26..c814468 100644 --- a/eu_fact_force/exploration/cytoscape/app.py +++ b/eu_fact_force/exploration/cytoscape/app.py @@ -40,6 +40,7 @@ DASHBOARD_NAME, style={ "color": AppColors.blue, + "font-weight": "bold", "margin": "0", "padding": "0", }, @@ -64,12 +65,19 @@ }, ) # Content -graph_example = cyto.Cytoscape( - id="graph", - elements=elements, - stylesheet=stylesheet, - layout={"name": "cose"}, - style={"width": "100%", "height": "400px"}, +graph_example = html.Div( + cyto.Cytoscape( + id="graph", + elements=elements, + stylesheet=stylesheet, + layout={"name": "cose"}, + style={"width": "100%", "height": "500px"}, + ), + style={ + "border-radius": "15px", + "padding": "20px", + "background-color": AppColors.white, + }, ) diff --git a/eu_fact_force/exploration/cytoscape/assets/custom.css b/eu_fact_force/exploration/cytoscape/assets/custom.css index 08aae40..77c6827 100644 --- a/eu_fact_force/exploration/cytoscape/assets/custom.css +++ b/eu_fact_force/exploration/cytoscape/assets/custom.css @@ -4,13 +4,14 @@ --color-0: #CBDF40; --color-1: #36C3D7; --color-2: #F5A414; + --color-gray: #F1F1F1; } /* Body and text style */ body { font-family: "Helvetica", sans-serif; - background-color: white; + background-color: var(--color-gray); font-size: 14px; } diff --git a/eu_fact_force/exploration/cytoscape/utils/colors.py b/eu_fact_force/exploration/cytoscape/utils/colors.py index bb2c2bc..95ce120 100644 --- a/eu_fact_force/exploration/cytoscape/utils/colors.py +++ b/eu_fact_force/exploration/cytoscape/utils/colors.py @@ -2,3 +2,5 @@ class AppColors: green = "#CBDF40" blue = "#36C3D7" orange = "#F5A414" + grey = "#F1F1F1" + white = "#FFFFFF" From dfa342c1c86f1b55f7a66da201c19c90f165eee0 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Fri, 13 Mar 2026 20:03:33 +0100 Subject: [PATCH 3/5] graph generator + search bar + list --- eu_fact_force/exploration/cytoscape/app.py | 134 ++++++++++++++++-- .../exploration/cytoscape/utils/graph.py | 103 ++++++++------ 2 files changed, 187 insertions(+), 50 deletions(-) diff --git a/eu_fact_force/exploration/cytoscape/app.py b/eu_fact_force/exploration/cytoscape/app.py index c814468..b796ef9 100644 --- a/eu_fact_force/exploration/cytoscape/app.py +++ b/eu_fact_force/exploration/cytoscape/app.py @@ -1,4 +1,6 @@ from dash import Dash, dcc, html +from dash.dependencies import Input, Output, State +from dash.exceptions import PreventUpdate import dash_bootstrap_components as dbc import plotly.io as pio import plotly.graph_objects as go @@ -7,7 +9,7 @@ import json from utils.colors import AppColors -from utils.graph import elements, stylesheet +from utils.graph import RandomGraphGenerator # Plotly template with open("assets/template.json", "r") as f: @@ -29,6 +31,9 @@ app.title = DASHBOARD_NAME app._favicon = "icon.png" +# Graph generator +generator = RandomGraphGenerator() + # Header header = html.Div( dbc.Row( @@ -65,35 +70,146 @@ }, ) # Content -graph_example = html.Div( - cyto.Cytoscape( - id="graph", - elements=elements, - stylesheet=stylesheet, +search_bar = html.Div( + children=[ + dbc.Row( + [ + dbc.Col( + dbc.Input( + id="search-input", + placeholder="Naratif de désinformation...", + style={"overflow": "hidden"}, + ) + ), + dbc.Col( + dbc.Button( + "Envoyer", + id="search-button", + color="primary", + className="me-1", + n_clicks=0, + disabled=True, + ), + width="auto", + ), + ], + align="center", + ) + ], + id="search", + style={ + "border-radius": "15px", + "padding": "20px", + "background-color": AppColors.white, + }, +) + +graph = html.Div( + children=cyto.Cytoscape( + id="graph-cytoscape", + stylesheet=generator.stylesheet, layout={"name": "cose"}, - style={"width": "100%", "height": "500px"}, + style={"width": "100%", "height": "400px"}, ), + id="graph", + style={ + "border-radius": "15px", + "padding": "20px", + "background-color": AppColors.white, + "display": "none", + }, +) + +list_elements = html.Div( + id="list", style={ "border-radius": "15px", "padding": "20px", "background-color": AppColors.white, + "display": "none", }, ) content = html.Div( - children=graph_example, - id="page-content", + [search_bar, html.Br(), graph, html.Br(), list_elements], style={ "margin-left": "1rem", "margin-right": "1rem", "padding": "1rem", "padding-top": "120px", }, + id="page-content", ) + # Layout app.layout = html.Div([dcc.Location(id="url", refresh=False), header, content]) + +# -------------------- +# Callbacks +# -------------------- + + +# Callback search button activate +@app.callback( + Output("search-button", "disabled"), + inputs=[Input("search-input", "value"), Input("graph", "children")], +) +def activate_search_buton(search_text, graph): + if search_text is None or search_text == "": + return True + else: + return False + + +# Callback update graph +@app.callback( + [ + Output("graph-cytoscape", "elements"), + Output("list", "children"), + Output("graph", "style"), + Output("list", "style"), + Output("search-input", "value"), + ], + inputs=[Input("search-button", "n_clicks")], + state=[State("search-input", "value")], + prevent_updates=True, +) +def update_graph(n_clicks, search_text): + if n_clicks > 0: + graph_elements = generator.get_graph_data() + list_elements = [x["data"] for x in graph_elements if "id" in x["data"]] + list_elements = sorted(list_elements, key=lambda x: x["id"]) + return [ + graph_elements, + dbc.Accordion( + [ + dbc.AccordionItem( + dcc.Markdown("\n".join([f"- {key} : {x[key]}" for key in x])), + title=x["label"], + ) + for x in list_elements + ] + ), + { + "border-radius": "15px", + "padding": "20px", + "background-color": AppColors.white, + "display": "block", + }, + { + "border-radius": "15px", + "padding": "20px", + "background-color": AppColors.white, + "display": "block", + }, + "", + ] + else: + raise PreventUpdate + + if __name__ == "__main__": app.run(debug=True) diff --git a/eu_fact_force/exploration/cytoscape/utils/graph.py b/eu_fact_force/exploration/cytoscape/utils/graph.py index dcd9e22..8a717ec 100644 --- a/eu_fact_force/exploration/cytoscape/utils/graph.py +++ b/eu_fact_force/exploration/cytoscape/utils/graph.py @@ -1,44 +1,65 @@ +import random + from .colors import AppColors -elements = [ - # Nodes - {"data": {"id": "1", "label": "Paper A", "type": "paper"}}, - {"data": {"id": "2", "label": "Paper B", "type": "paper"}}, - {"data": {"id": "3", "label": "Paper C", "type": "paper"}}, - {"data": {"id": "4", "label": "Journal A", "type": "journal"}}, - {"data": {"id": "5", "label": "Journal B", "type": "journal"}}, - # Edges - {"data": {"source": "1", "target": "4"}}, - {"data": {"source": "2", "target": "4"}}, - {"data": {"source": "3", "target": "5"}}, -] -stylesheet = [ - { - "selector": "node", - "style": { - "label": "data(label)", - "text-valign": "center", - "color": "black", - }, - }, - { - "selector": 'node[type="paper"]', - "style": { - "background-color": AppColors.blue, - }, - }, - { - "selector": 'node[type="journal"]', - "style": { - "background-color": AppColors.green, - }, - }, - { - "selector": "edge", - "style": { - "width": 2, - "line-color": "black", - }, - }, -] +class RandomGraphGenerator: + def __init__(self): + self.n_min_paper_nodes = 5 + self.n_max_paper_nodes = 10 + self.nodes_paper = [ + {"data": {"id": f"node_paper_{i}", "label": f"Paper {i}", "type": "paper"}} + for i in range(self.n_max_paper_nodes) + ] + self.nodes_journal = [ + {"data": {"id": "node_journal_0", "label": "Journal A", "type": "journal"}}, + {"data": {"id": "node_journal_1", "label": "Journal B", "type": "journal"}}, + {"data": {"id": "node_journal_2", "label": "Journal C", "type": "journal"}}, + ] + self.stylesheet = [ + { + "selector": "node", + "style": { + "label": "data(label)", + "text-valign": "center", + "color": "black", + }, + }, + { + "selector": 'node[type="paper"]', + "style": { + "background-color": AppColors.blue, + }, + }, + { + "selector": 'node[type="journal"]', + "style": { + "background-color": AppColors.green, + }, + }, + { + "selector": "edge", + "style": { + "width": 2, + "line-color": "black", + }, + }, + ] + + def get_graph_data(self): + nodes = random.sample( + self.nodes_paper, + random.randint(self.n_min_paper_nodes, self.n_max_paper_nodes), + ) + edges = [] + for source_node in nodes: + target_node = random.sample(self.nodes_journal, 1)[0] + edges.append( + { + "data": { + "source": source_node["data"]["id"], + "target": target_node["data"]["id"], + } + } + ) + return nodes + self.nodes_journal + edges From 3e1f740a08bb18a2e68e2f2d7256644373acf4f8 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Fri, 13 Mar 2026 22:51:05 +0100 Subject: [PATCH 4/5] offcanvas --- eu_fact_force/exploration/cytoscape/app.py | 47 ++++++++++++++++++++-- 1 file changed, 43 insertions(+), 4 deletions(-) diff --git a/eu_fact_force/exploration/cytoscape/app.py b/eu_fact_force/exploration/cytoscape/app.py index b796ef9..b0b918a 100644 --- a/eu_fact_force/exploration/cytoscape/app.py +++ b/eu_fact_force/exploration/cytoscape/app.py @@ -83,7 +83,7 @@ ), dbc.Col( dbc.Button( - "Envoyer", + "Rechercher", id="search-button", color="primary", className="me-1", @@ -130,9 +130,17 @@ }, ) +offcanevas = dbc.Offcanvas( + id="offcanvas", + title="Selectionné", + is_open=False, + placement="end", + style={"width": "50%"}, +) + content = html.Div( - [search_bar, html.Br(), graph, html.Br(), list_elements], + [search_bar, html.Br(), graph, html.Br(), list_elements, offcanevas], style={ "margin-left": "1rem", "margin-right": "1rem", @@ -187,11 +195,16 @@ def update_graph(n_clicks, search_text): dbc.Accordion( [ dbc.AccordionItem( - dcc.Markdown("\n".join([f"- {key} : {x[key]}" for key in x])), + dcc.Markdown( + "\n".join( + [f"- {key.capitalize()} : __{x[key]}__" for key in x] + ) + ), title=x["label"], ) for x in list_elements - ] + ], + start_collapsed=True, ), { "border-radius": "15px", @@ -211,5 +224,31 @@ def update_graph(n_clicks, search_text): raise PreventUpdate +# Callback show selected element +@app.callback( + [ + Output("offcanvas", "is_open"), + Output("offcanvas", "children"), + ], + inputs=[Input("graph-cytoscape", "tapNodeData")], + state=[State("offcanvas", "is_open")], + prevent_initial_call=True, +) +def toggle_offcanvas(node_data, is_open): + if node_data: + return [ + not is_open, + dcc.Markdown( + "\n".join( + [ + f"- {key.capitalize()} : __{node_data[key]}__" + for key in node_data + if key != "timeStamp" + ] + ) + ), + ] + + if __name__ == "__main__": app.run(debug=True) From 405485dc7d3450c883e83b80f8eff61afcb2ec09 Mon Sep 17 00:00:00 2001 From: Hugo De Oliveira <80337112+hugros-93@users.noreply.github.com> Date: Sun, 15 Mar 2026 08:24:39 +0100 Subject: [PATCH 5/5] Add readme --- eu_fact_force/exploration/cytoscape/README.md | 21 ++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/eu_fact_force/exploration/cytoscape/README.md b/eu_fact_force/exploration/cytoscape/README.md index 68e1a55..5fc0a4d 100644 --- a/eu_fact_force/exploration/cytoscape/README.md +++ b/eu_fact_force/exploration/cytoscape/README.md @@ -1 +1,20 @@ -### Exploration - Dash app for testing Cytoscape \ No newline at end of file +# Exploration - Dash Cytoscape + +This folder contains a first version of a local Dash app to explore Cytoscape capabilities. + +# Repo structure +- `app.py`: the main app file. +- `assets/`: the app asset folder, with custom css, icon and plotly template. +- `utils/`: app utility files, including d4g colors and random graph generator. + + +## Setup +- Install `graph` group depedencies using `uv sync --group graph`. +- Start app from here with `pyhon app.py`. +- Visit `http://127.0.0.1:8050/` to see the app on your local. + +## App overview +- This app contains a search bar with an "Search" button to simulate search. +- On search button click, a random network graph will be generated. +- Clicking on a node in the chart will open an offcanevas displaying node metadata. +- A list of all nodes in the graph will also be generated, with node metadatWHen a in each element. \ No newline at end of file