Jump to content

Module:CineMol

From Wikipedia, the free encyclopedia

local p = {}


local compress = require( 'Module:libDeflate' )
require( 'strict' )
local m = require( 'Module:CineMol/api' )
local parse_sdf = require( 'Module:CineMol/parsers' ).parse_sdf
local getArgs = require( 'Module:Arguments' ).getArgs
local yesno = require( 'Module:Yesno' )

-- Stolen from Module:Cite_taxon
local b64decode = function(data)
    local b='ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
    data = string.gsub(data, '[^'..b..'=]', '') 
    return (data:gsub('.', function(x)
        if (x == '=') then return '' end 
        local r,f='',(b:find(x)-1)
        for i=6,1,-1 do r=r..(f%2^i-f%2^(i-1)>0 and '1' or '0') end 
        return r;
    end):gsub('%d%d%d?%d?%d?%d?%d?%d?', function(x)
        if (#x ~= 8) then return '' end 
        local c=0 
        for i=1,8 do c=c+(x:sub(i,i)=='1' and 2^(8-i) or 0) end 
        return string.char(c)
    end))
end

-- Keep in sync with Category:Tabular_data_of_the_Chemical_Component_Dictionary
local function getKey( id )
	if string.sub( id, 1, 1 ) == 'A' then
		local letter2 = #id <= 1 and 0 or string.byte( id, 2 )
		local letter3 = #id <= 2 and 0 or string.byte( id, 3 )
		if
			letter2 < string.byte( '1' ) or
			( string.sub( id, 1, 2 ) == 'A1' and letter3 < string.byte( 'B' ) )
		then
			return 'A'
		elseif string.sub( id, 1, 2 ) == 'A1' and letter3 < string.byte( 'I' ) then
			return 'A1B';
		elseif string.sub( id, 1, 2 ) == 'A1' then
			return 'A1I'
		else
			return 'A2'
		end
	else
		return string.sub( id, 1, 1)
	end
end

local function is2D( mol )
	return string.match( mol, "^[^\n]*\n[^\n]*(2D)[\n ]" ) == '2D'
end

local function getMolFromCCD( id )
	local data = mw.ext.data.get( string.format( "Chemical Component Dictionary/%s.tab", getKey(id) ) )
	assert( data ~= false, 'Could not fetch CCD file from commons')
	local ccd = data['data']
	local molEncoded = ''
	for i = 1,#ccd do
		-- This assumes entries are in order.
		-- if this becomes an efficiency issue, we could binary search it instead.
		if ccd[i][1] == id then
			molEncoded = molEncoded .. ccd[i][3]
		end
	end
	assert( molEncoded ~= '', 'Could not find PDB-lignad id: ' .. id )
	return compress:DecompressDeflate( b64decode( molEncoded ) )
end

local function getMolFromChEBI( id )
	if string.upper(string.sub( id, 1, 6 )) == 'CHEBI:' then
		id = string.sub( id, 7 )
	end
	local idNumb = tonumber( id )
	local key
	if idNumb < 160000 then
		key = math.floor( idNumb / 2000 ) * 2000
	elseif idNumb < 200000 then
		key = math.floor( idNumb / 20000 ) * 20000
	else
		key = 200000
	end
	local data = mw.ext.data.get( string.format( "ChEBI/%d.tab", key ) )
	assert( data ~= false, 'Could not fetch ChEBI data file from commons')
	local dataset = data['data']
	local molEncoded = ''
	for i = 1,#dataset do
		-- This assumes entries are in order.
		-- if this becomes an efficiency issue, we could binary search it instead.
		if dataset[i][1] == id then
			molEncoded = molEncoded .. dataset[i][4]
		end
	end
	assert( molEncoded ~= '', 'Could not find ChEBI: ' .. id )
	return compress:DecompressDeflate( b64decode( molEncoded ) )
end

p.styleMap = {
	spacefilling = m.Style.SPACEFILLING,
	space_filling = m.Style.SPACEFILLING,
	["space filling"] = m.Style.SPACEFILLING,
	ballandstick = m.Style.BALL_AND_STICK,
	ball_and_stick = m.Style.BALL_AND_STICK,
	["ball and stick"] = m.Style.BALL_AND_STICK,
	tube = m.Style.TUBE,
	wireframe = m.Style.WIREFRAME
}

p.lookMap = {
	cartoon = m.Look.CARTOON,
	glossy = m.Look.GLOSSY
}

function p.draw_mol(frame)
	local args = getArgs( frame )
	return frame:extensionTag( "TemplateStyles", "", {src="Module:CineMol/styles.css"} )
		.. p.draw_mol_internal(args)
end

-- Get automatic caption if using a ligand id
local function getAutoCaptionPdb(ligand)
	local license = '<div class="cinemol-license-img" title="Public domain">[[File:Cc.logo.circle.svg|28px|class=skin-invert notpageimage|link=|Creative Commons]]&nbsp;[[File:Cc-zero.svg|28px|class=skin-invert notpageimage|link=https://creativecommons.org/publicdomain/zero/1.0/deed.en|CC-Zero]]</div>'
	return license .. 'Rendered based on the entry in the [https://www.wwpdb.org/data/ccd Chemical Component Dictionary] for the molecule with id "[https://www.rcsb.org/ligand/' .. ligand .. ' ' .. ligand .. ']".'
end

local function getAutoCaptionChEBI(id)
	local license = '<div class="cinemol-license-img" title="Creative Commons Attribution 4.0">[[File:Cc.logo.circle.svg|28px|class=skin-invert notpageimage|link=https://creativecommons.org/licenses/by/4.0/deed.en|Creative Commons]]&nbsp;[[File:Cc-by_new_white.svg|28px|class=skin-invert notpageimage|link=https://creativecommons.org/licenses/by/4.0/deed.en|CC-BY 4.0]]</div>'
	return license .. 'Rendered based on the entry in the [[w:ChEBI|Chemical Entities of Biological Interest]] for the molecule with id "[https://www.ebi.ac.uk/chebi/searchId.do?chebiId=CHEBI:' .. id .. ' CHEBI:' .. id .. ']".'
end

function p.draw_mol_internal(args)
	local style = args.style == nil and m.Style.SPACEFILLING or p.styleMap[string.lower(args.style)]
	assert( style ~= nil, 'Unrecognized style' )
	local look = args.look == nil and m.Look.GLOSSY or p.lookMap[string.lower(args.look)]
	assert( look ~= nil, 'Unrecognized look' )
	local resolution = args.resolution == nil and 30 or tonumber(args.resolution)
	assert( type(resolution) == 'number', 'Resolution must be number' )
	-- We can never zoom in so far that the molecule is out of frame, so best this default
	-- a high number.
	local scale = args.scale == nil and 5 or tonumber(args.scale)
	assert( type(scale) == 'number', 'Scale must be number' )
	local focal_length = args.focal_length ~= nil and tonumber(args.focal_length) or nil
	local rx = args.rx == nil and 0 or tonumber(args.rx)
	local ry = args.ry == nil and 0 or tonumber(args.ry)
	local rz = args.rz == nil and 0 or tonumber(args.rz)
	local useLightbox = yesno( args.lightbox, true ) or true
	local hydrogen = yesno( args.include_hydrogen, false ) or false
	local exclude = hydrogen and {} or {'H'}
	local width = args.width
	if args.height == nil and args.width == nil then
		width = "250"
	end
	local internalLink = nil
	local externalLink = nil
	if args.link ~= nil then
		if string.match( args.link, '^https?://' ) or string.sub( args.link, 1, 2 ) == '//' then
			externalLink = args.link
			useLightbox = false
		else
			internalLink = args.link
			useLightbox = false
		end
	end

	local lightboxCaption = args.lightbox_caption
	local mol
	if args.mol ~= nil and args.mol ~= '' then
		mol = args.mol
	elseif args['pdb-ligand'] ~= nil and args['pdb-ligand'] ~= '' then
		mol = getMolFromCCD( args['pdb-ligand'] )
		lightboxCaption = lightboxCaption == nil and getAutoCaptionPdb( args['pdb-ligand'] ) or lightboxCaption
	elseif args.ChEBI ~= nil and args.ChEBI ~= '' then
		mol = getMolFromChEBI( args.ChEBI )
		lightboxCaption = lightboxCaption == nil and getAutoCaptionChEBI( args.ChEBI ) or lightboxCaption
	else
		local wd = args.wikidata == nil and mw.wikibase.getEntityIdForCurrentPage() or args.wikidata
		if wd ~= nil then
			local stmt = mw.wikibase.getBestStatements( wd, 'P3636' )
			if stmt and stmt[1] and stmt[1].mainsnak then
				local ccd = stmt[1].mainsnak.datavalue.value
				mol = getMolFromCCD( ccd )
				lightboxCaption = lightboxCaption == nil and getAutoCaptionPdb( ccd ) or lightboxCaption
			else
				stmt =  mw.wikibase.getBestStatements( wd, 'P683' )
				if stmt and stmt[1] and stmt[1].mainsnak then
					local ChEBI = stmt[1].mainsnak.datavalue.value
					mol = getMolFromChEBI( ChEBI )
					lightboxCaption = lightboxCaption == nil and getAutoCaptionChEBI( ChEBI ) or lightboxCaption
				end
			end
		end
	end

	assert( mol ~= nil and mol ~= '', 'Either mol, pdf-ligand, ChEBI or wikidata argument is required or the current page has to be connected to a wikidata item with P3636' )

	local style = args.style == nil and ( is2D(mol) and m.Style.WIREFRAME or m.Style.SPACEFILLING ) or p.styleMap[string.lower(args.style)]
	assert( style ~= nil, 'Unrecognized style' )

	-- Actual template
	local atoms, bonds = parse_sdf( mol )
	local molSvg = m.draw_molecule( atoms, bonds, style, look, resolution, nil, nil, rx, ry, rz, scale, focal_length, exclude )

	local mw_svg = molSvg
		:to_mw_svg()
		
	if ( width ) then
		mw_svg:setImgAttribute( 'width', tostring( width ) )
	end
	if ( args.css ) then
		mw_svg:setImgAttribute( 'style', tostring( args.css ) )
	end
	if ( args.alt ) then
		mw_svg:setImgAttribute( 'alt', tostring( args.alt ) )
	end
	if ( args.class ) then
	end
	if ( args.height ) then
		mw_svg:setImgAttribute( 'height', tostring( args.height ) )
	end
	if ( args.title ) then
		mw_svg:setImgAttribute( 'title', tostring( args.title ) )
	end
	local class = 'cinemol-img'
	if args.class then
		class = class .. ' ' .. args.class
	end
	mw_svg:setImgAttribute( 'class', class )

	local start = '<div class="plainlinks cinemol-container calculator-container">'
	local tail = '</div>'
	if useLightbox then
		start = start .. '<div class="calculator-field cinemol-inner-container" data-calculator-type="passthru" id="calculator-field-lightbox">'
		tail = '</div>' .. tail
	end

	if lightboxCaption and useLightbox then
		-- potentially should have an aria-described-by pointing to it.
		tail = '<div class="cinemol-lightbox-caption">' .. lightboxCaption .. '</div>' .. tail
	end
	if useLightbox then
		start = start .. '<div class="cinemol-lightbox-controls">' ..  mw.getCurrentFrame():preprocess('{{calculator button|contents=×|style=appearance:none|alt=Exit molecule lightbox view|title=Exit molecule lightbox view|for=lightbox|formula=0|type=plain}}') .. '</div>'

		-- For now you can only open by click not by tab. It is unclear if there is any value for a screen reader
		-- to zoom in on an image.
		start = start .. '<div class="cinemol-inner calculator-field-buttonraw" data-calculator-for="lightbox" data-calculator-formula="1">'
		tail = '</div>' .. tail
	end
	
	if internalLink then
		start = start .. '[[' .. internalLink .. '|'
		tail =  ']]' .. tail
	elseif externalLink then
		start = start .. '[' .. externalLink .. ' '
		tail =  ']' .. tail
	end

	return start .. mw_svg:toImage() .. tail
end

-- Demo directly using the api
function p.spacefilling(frame)
		local width = frame.args.width == nil and '250' or frame.args.width
        local svg = m.draw_molecule(
        	{
            	m.Atom(0, "C", {0, 0, 0}),
            	m.Atom(1, "H", {1, 0, 0}),
            	m.Atom(2, "H", {0, 1, 0}),
            	m.Atom(3, "H", {0, 0, 1})
        	},
        	{
            	m.Bond(0, 1, 1),
            	m.Bond(0, 2, 1),
            	m.Bond(0, 3, 1),
        	},
            m.Style.SPACEFILLING,
            m.Look.GLOSSY,
            50
        )
		return svg:to_mw_svg():setImgAttribute( 'width', tostring(width) ):toImage()
end

return p