1- "use client"
1+ "use client" ;
22
3+
4+ import {
5+ Container ,
6+ Flex ,
7+ Anchor ,
8+ ActionIcon ,
9+ useMantineColorScheme ,
10+ } from "@mantine/core" ;
11+ import { IconMoon , IconSun } from "@tabler/icons-react" ;
12+ import Link from "next/link" ;
13+
14+
15+ import React , { useState } from "react" ;
16+ import { Textarea , Group , Chip , Stack , Text , Loader , Paper , Button } from "@mantine/core" ;
317import JSME from "../../components/tools/toolViz/JSMEComp" ;
418
5- export default function JSMEPOPO ( ) {
6- function handleChange ( smiles ) {
7- console . log ( smiles )
19+ type TargetSummary = {
20+ id : string ; // original input (ChEMBL or UniProt)
21+ resolvedId : string ; // ChEMBL target id used for activity query
22+ totalCount : number | null ;
23+ error ?: string ;
24+ } ;
25+
26+ async function resolveToChemblTargetId ( rawId : string ) : Promise < { resolvedId ?: string ; error ?: string } > {
27+ const id = rawId . trim ( ) ;
28+
29+ // Heuristic: if it already looks like a ChEMBL target, use it directly.
30+ // (e.g. CHEMBL226, CHEMBL203, etc.) [web:9]
31+ if ( / ^ C H E M B L \d + $ / i. test ( id ) ) {
32+ return { resolvedId : id . toUpperCase ( ) } ;
33+ }
34+
35+ // Otherwise treat as UniProt accession and map via target endpoint. [web:9][web:11]
36+ const url = `https://www.ebi.ac.uk/chembl/api/data/target.json?target_components__accession=${ encodeURIComponent (
37+ id
38+ ) } `;
39+
40+ const res = await fetch ( url ) ;
41+ if ( ! res . ok ) {
42+ return { error : `Target lookup HTTP ${ res . status } ` } ;
43+ }
44+
45+ const json = await res . json ( ) ;
46+ const first = json ?. targets ?. [ 0 ] ;
47+ const chemblId = first ?. target_chembl_id ;
48+
49+ if ( ! chemblId ) {
50+ return { error : "No ChEMBL target found for this UniProt ID" } ;
51+ }
52+
53+ return { resolvedId : chemblId } ;
54+ }
55+
56+ async function fetchTotalCountForTarget ( rawId : string ) : Promise < TargetSummary > {
57+ const { resolvedId, error : resolveError } = await resolveToChemblTargetId ( rawId ) ;
58+
59+ if ( ! resolvedId ) {
60+ return { id : rawId , resolvedId : rawId , totalCount : null , error : resolveError ?? "Unable to resolve ID" } ;
61+ }
62+
63+ const url = `https://www.ebi.ac.uk/chembl/api/data/activity.json?target_chembl_id=${ encodeURIComponent (
64+ resolvedId
65+ ) } `;
66+
67+ const res = await fetch ( url ) ;
68+ if ( ! res . ok ) {
69+ return {
70+ id : rawId ,
71+ resolvedId,
72+ totalCount : null ,
73+ error : `Activity HTTP ${ res . status } ` ,
74+ } ;
75+ }
76+
77+ const json = await res . json ( ) ;
78+ // page_meta.total_count holds the total number of activity records. [file:1][web:9]
79+ const totalCount = json ?. page_meta ?. total_count ?? null ;
80+
81+ return { id : rawId , resolvedId, totalCount } ;
82+ }
83+
84+ function ChemblActivityCounter ( ) {
85+ const [ input , setInput ] = useState ( "" ) ;
86+ const [ targets , setTargets ] = useState < string [ ] > ( [ ] ) ;
87+ const [ results , setResults ] = useState < TargetSummary [ ] > ( [ ] ) ;
88+ const [ loading , setLoading ] = useState ( false ) ;
89+
90+ const parseTargets = ( raw : string ) : string [ ] => {
91+ const parts = raw
92+ . split ( / [ \s , ; ] + / )
93+ . map ( ( t ) => t . trim ( ) )
94+ . filter ( Boolean ) ;
95+ return Array . from ( new Set ( parts ) ) ;
96+ } ;
97+
98+ const handleInputChange = ( value : string ) => {
99+ setInput ( value ) ;
100+ const parsed = parseTargets ( value ) ;
101+ setTargets ( parsed ) ; // tags update automatically
102+ setResults ( [ ] ) ;
103+ } ;
104+
105+ const handleRemoveTag = ( id : string ) => {
106+ const remaining = targets . filter ( ( t ) => t !== id ) ;
107+ setTargets ( remaining ) ;
108+ setInput ( remaining . join ( ", " ) ) ;
109+ setResults ( ( prev ) => prev . filter ( ( r ) => r . id !== id ) ) ;
110+ } ;
111+
112+ const handleRunQuery = async ( ) => {
113+ if ( ! targets . length ) return ;
114+ setLoading ( true ) ;
115+ try {
116+ const summaries = await Promise . all ( targets . map ( fetchTotalCountForTarget ) ) ;
117+ setResults ( summaries ) ;
118+ } finally {
119+ setLoading ( false ) ;
8120 }
9- return (
10- < div className = "container" >
11- < div className = "content-wrapper" style = { { marginTop : "60px" } } >
12- < JSME height = "500px" width = "500px" onChange = { handleChange } />
13- </ div >
14- </ div >
15- )
16- }
121+ } ;
122+
123+ return (
124+ < Paper withBorder shadow = "sm" p = "md" mt = "md" >
125+ < Stack gap = "md" >
126+ < Textarea
127+ label = "Targets (ChEMBL or UniProt)"
128+ description = "Paste ChEMBL target IDs (e.g. CHEMBL226) or UniProt accessions (e.g. P05067)."
129+ placeholder = { `CHEMBL226
130+ P05067
131+ CHEMBL203` }
132+ minRows = { 4 }
133+ value = { input }
134+ onChange = { ( event ) => handleInputChange ( event . currentTarget . value ) }
135+ />
136+
137+ < Group justify = "space-between" >
138+ < Text size = "sm" c = "dimmed" >
139+ IDs are automatically converted into tags below.
140+ </ Text >
141+ < Button onClick = { handleRunQuery } disabled = { ! targets . length || loading } >
142+ { loading ? < Loader size = "xs" /> : "Run query" }
143+ </ Button >
144+ </ Group >
145+
146+ { targets . length > 0 && (
147+ < Stack gap = "xs" >
148+ < Text size = "sm" > Input IDs:</ Text >
149+ < Group gap = "xs" >
150+ { targets . map ( ( id ) => (
151+ < Chip
152+ key = { id }
153+ checked
154+ onChange = { ( ) => handleRemoveTag ( id ) }
155+ color = "blue"
156+ radius = "sm"
157+ >
158+ { id }
159+ </ Chip >
160+ ) ) }
161+ </ Group >
162+ < Text size = "xs" c = "dimmed" >
163+ Click a tag to remove it.
164+ </ Text >
165+ </ Stack >
166+ ) }
167+
168+ { results . length > 0 && (
169+ < Stack gap = "xs" >
170+ < Text fw = { 500 } > Activity counts per target</ Text >
171+ { results . map ( ( r ) => (
172+ < Text key = { r . id } size = "sm" >
173+ { r . id }
174+ { r . resolvedId && r . resolvedId !== r . id ? ` → ${ r . resolvedId } ` : "" } :{ " " }
175+ { r . error
176+ ? `Error: ${ r . error } `
177+ : r . totalCount !== null
178+ ? `${ r . totalCount } Compounds With Activity`
179+ : "No total_count found" }
180+ </ Text >
181+ ) ) }
182+ </ Stack >
183+ ) }
184+ </ Stack >
185+ </ Paper >
186+ ) ;
187+ }
188+
189+ export default function JSMEPOPO ( ) {
190+ const { colorScheme, setColorScheme } = useMantineColorScheme ( ) ;
191+
192+ function handleChange ( smiles : string ) {
193+ console . log ( smiles ) ;
194+ }
195+
196+ return (
197+ < >
198+ { /* Navbar */ }
199+ < Container size = "lg" py = "md" >
200+ < Flex align = "center" justify = "space-between" >
201+ { /* Left: Title */ }
202+ < Text size = "lg" fw = { 600 } >
203+ < Link href = "/" style = { { textDecoration : "none" , color : "inherit" } } >
204+ QSAR IN THE BROWSER
205+ </ Link >
206+ </ Text >
207+
208+ { /* Right: About link and theme button */ }
209+ < Group gap = "sm" >
210+ < Anchor
211+ component = { Link }
212+ href = "/about"
213+ size = "sm"
214+ underline = "hover"
215+ c = "dimmed"
216+ >
217+ About
218+ </ Anchor >
219+
220+ < Anchor
221+ component = { Link }
222+ href = "/about"
223+ size = "sm"
224+ underline = "hover"
225+ c = "dimmed"
226+ >
227+ GitHub
228+ </ Anchor >
229+
230+ < ActionIcon
231+ onClick = { ( ) =>
232+ setColorScheme ( colorScheme === "light" ? "dark" : "light" )
233+ }
234+ variant = "default"
235+ size = "lg"
236+ radius = "md"
237+ aria-label = "Toggle color scheme"
238+ >
239+ { colorScheme === "dark" ? (
240+ < IconSun stroke = { 1.5 } />
241+ ) : (
242+ < IconMoon stroke = { 1.5 } />
243+ ) }
244+ </ ActionIcon >
245+ </ Group >
246+ </ Flex >
247+ </ Container >
248+ < Container size = "lg" py = "xl" >
249+ < JSME height = "500px" width = "500px" onChange = { handleChange } />
250+ < ChemblActivityCounter />
251+ </ Container >
252+ </ >
253+ ) ;
254+ }
0 commit comments